Skip to content

Commit 429e23d

Browse files
authored
fix(geometry): scale SAT triangle-plane epsilon by normal magnitude (#646)
* fix(geometry): scale SAT triangle-plane epsilon by normal magnitude Wall outer faces sitting f32-coplanar with the opening box's max-axis face survived the void cut on Smiley-West (issue #584) because the SAT triangle-plane separation test used a fixed `1e-6` epsilon while its operands (`triangle_offset`, `box_projection`) both scale with `|triangle_normal|` ~= 2 * triangle_area. For a 2.0 m x 2.6 m wall face, |triangle_normal| ~= 5.2, so the projection-space gap from a ~3e-7 m physical offset (the f32 -> f64 round-trip slop between the wall mesh and the f64 opening box) becomes ~1.7e-6 in projection units -- larger than the fixed 1e-6 -- and `triangle_intersects_box` incorrectly reported the face as separated. The clipper then skipped those triangles and the wall stayed uncut. Fix: multiply SAT_EPSILON by `max(|triangle_normal|, 1.0)` for the triangle-plane test only. The box-axis tests (T1) and edge-cross tests (T3) continue to use the fixed epsilon since they project onto unit-length axes. Also harden T1/T3 with the same `+/- SAT_EPSILON` slack the prior agent added (was already in this branch's working diff) so all three SAT separation tests remain consistent. Repro coverage: * `tests/production_void_path_test.rs::smiley_584_all_host_walls_have_holes` exercises every host wall in AC-20-Smiley-West-10-Bldg.ifc through the production `process_element_with_voids` path, builds the `void_index` from `IfcRelVoidsElement` exactly as `process_geometry_batch` does, and ray-casts a 3x3 grid through each opening footprint along the wall thickness axis. Pre-fix: 4/190 walls (#49545, #50281, #61905, #87842) returned 9 hits each -- fully solid. Post-fix: 0/190. * `door_604_wall_55_has_a_hole` covers issue #604's regression case. * `smiley_584_wall_0nqvcw_has_holes` covers the user-reported wall GUID `0NQVcwUgj2fup5UuFaDTfC` with two balcony openings. The door.ifc fixture is committed so CI can run without an external fetch; AC-20-Smiley-West-10-Bldg.ifc is fetched on demand via `pnpm fixtures` and the test skips if missing. Closes #584. * chore(wasm): rebuild artifacts with SAT triangle-plane epsilon fix Picks up the void-cut correctness fix from the previous commit so production gets the un-cut wall fix without waiting for a new release. Both single-thread and threaded bundles rebuilt at -O3.
1 parent 9d5f927 commit 429e23d

11 files changed

Lines changed: 3034 additions & 33 deletions

File tree

Cargo.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/wasm-threaded/pkg/ifc-lite.d.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,22 @@ export class IfcAPI {
607607
* multiple loads with different files.
608608
*/
609609
setEntityIndex(ids: Uint32Array, starts: Uint32Array, lengths: Uint32Array): void;
610+
/**
611+
* Toggle the "render multilayer walls as a single solid" mode (issue #540).
612+
*
613+
* When `enabled` is `true`, every subsequent `parseMeshes*` call will
614+
* suppress geometry emission for `IfcBuildingElementPart` entities whose
615+
* `IfcRelAggregates` parent wall is sliceable (has an
616+
* `IfcMaterialLayerSetUsage`) AND has its own `Representation`. The
617+
* parent wall keeps its per-layer sub-mesh colouring, so the visual
618+
* result is the same as the layered render but with one mesh per wall
619+
* instead of one per layer part — much cheaper for both CPU and GPU.
620+
*
621+
* Default is `false`. Pass `true` before calling `parseMeshes`,
622+
* `parseMeshesSubset`, `parseMeshesAsync`, `parseMeshesInstanced`, or
623+
* `parseMeshesInstancedAsync`.
624+
*/
625+
setMergeLayers(enabled: boolean): void;
610626
/**
611627
* Clear the cached entity index (call between loads when reusing
612628
* the same `IfcAPI` instance — e.g. the parser worker keeps one
@@ -1245,6 +1261,7 @@ export interface InitOutput {
12451261
readonly ifcapi_scanGeometryEntitiesFast: (a: number, b: number, c: number) => number;
12461262
readonly ifcapi_scanRelevantEntitiesFastBytes: (a: number, b: number, c: number) => number;
12471263
readonly ifcapi_setEntityIndex: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void;
1264+
readonly ifcapi_setMergeLayers: (a: number, b: number) => void;
12481265
readonly ifcapi_version: (a: number, b: number) => void;
12491266
readonly instancedata_color: (a: number, b: number) => void;
12501267
readonly instancedata_expressId: (a: number) => number;
@@ -1357,9 +1374,9 @@ export interface InitOutput {
13571374
readonly wbg_rayon_poolbuilder_numThreads: (a: number) => number;
13581375
readonly wbg_rayon_poolbuilder_receiver: (a: number) => number;
13591376
readonly wbg_rayon_start_worker: (a: number) => void;
1360-
readonly __wasm_bindgen_func_elem_1092: (a: number, b: number, c: number) => void;
1361-
readonly __wasm_bindgen_func_elem_1091: (a: number, b: number) => void;
1362-
readonly __wasm_bindgen_func_elem_1363: (a: number, b: number, c: number, d: number) => void;
1377+
readonly __wasm_bindgen_func_elem_1102: (a: number, b: number, c: number) => void;
1378+
readonly __wasm_bindgen_func_elem_1101: (a: number, b: number) => void;
1379+
readonly __wasm_bindgen_func_elem_1373: (a: number, b: number, c: number, d: number) => void;
13631380
readonly memory: WebAssembly.Memory;
13641381
readonly __wbindgen_export: (a: number) => void;
13651382
readonly __wbindgen_export2: (a: number, b: number, c: number) => void;

packages/wasm-threaded/pkg/ifc-lite.js

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -223,12 +223,12 @@ if (cachedTextEncoder) {
223223

224224
let WASM_VECTOR_LEN = 0;
225225

226-
function __wasm_bindgen_func_elem_1092(arg0, arg1, arg2) {
227-
wasm.__wasm_bindgen_func_elem_1092(arg0, arg1, addHeapObject(arg2));
226+
function __wasm_bindgen_func_elem_1102(arg0, arg1, arg2) {
227+
wasm.__wasm_bindgen_func_elem_1102(arg0, arg1, addHeapObject(arg2));
228228
}
229229

230-
function __wasm_bindgen_func_elem_1363(arg0, arg1, arg2, arg3) {
231-
wasm.__wasm_bindgen_func_elem_1363(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
230+
function __wasm_bindgen_func_elem_1373(arg0, arg1, arg2, arg3) {
231+
wasm.__wasm_bindgen_func_elem_1373(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
232232
}
233233

234234
const GeoReferenceJsFinalization = (typeof FinalizationRegistry === 'undefined')
@@ -1793,6 +1793,25 @@ export class IfcAPI {
17931793
const len2 = WASM_VECTOR_LEN;
17941794
wasm.ifcapi_setEntityIndex(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
17951795
}
1796+
/**
1797+
* Toggle the "render multilayer walls as a single solid" mode (issue #540).
1798+
*
1799+
* When `enabled` is `true`, every subsequent `parseMeshes*` call will
1800+
* suppress geometry emission for `IfcBuildingElementPart` entities whose
1801+
* `IfcRelAggregates` parent wall is sliceable (has an
1802+
* `IfcMaterialLayerSetUsage`) AND has its own `Representation`. The
1803+
* parent wall keeps its per-layer sub-mesh colouring, so the visual
1804+
* result is the same as the layered render but with one mesh per wall
1805+
* instead of one per layer part — much cheaper for both CPU and GPU.
1806+
*
1807+
* Default is `false`. Pass `true` before calling `parseMeshes`,
1808+
* `parseMeshesSubset`, `parseMeshesAsync`, `parseMeshesInstanced`, or
1809+
* `parseMeshesInstancedAsync`.
1810+
* @param {boolean} enabled
1811+
*/
1812+
setMergeLayers(enabled) {
1813+
wasm.ifcapi_setMergeLayers(this.__wbg_ptr, enabled);
1814+
}
17961815
/**
17971816
* Clear the cached entity index (call between loads when reusing
17981817
* the same `IfcAPI` instance — e.g. the parser worker keeps one
@@ -3440,7 +3459,7 @@ function __wbg_get_imports(memory) {
34403459
const a = state0.a;
34413460
state0.a = 0;
34423461
try {
3443-
return __wasm_bindgen_func_elem_1363(a, state0.b, arg0, arg1);
3462+
return __wasm_bindgen_func_elem_1373(a, state0.b, arg0, arg1);
34443463
} finally {
34453464
state0.a = a;
34463465
}
@@ -3572,26 +3591,26 @@ function __wbg_get_imports(memory) {
35723591
imports.wbg.__wbg_warn_6e567d0d926ff881 = function(arg0) {
35733592
console.warn(getObject(arg0));
35743593
};
3594+
imports.wbg.__wbindgen_cast_14f34dd6042f46b7 = function(arg0, arg1) {
3595+
// Cast intrinsic for `Closure(Closure { dtor_idx: 167, function: Function { arguments: [Externref], shim_idx: 168, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3596+
const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_1101, __wasm_bindgen_func_elem_1102);
3597+
return addHeapObject(ret);
3598+
};
35753599
imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) {
35763600
// Cast intrinsic for `Ref(String) -> Externref`.
35773601
const ret = getStringFromWasm0(arg0, arg1);
35783602
return addHeapObject(ret);
35793603
};
3604+
imports.wbg.__wbindgen_cast_42c169aabd4aa5c0 = function(arg0, arg1) {
3605+
// Cast intrinsic for `Closure(Closure { dtor_idx: 167, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 168, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3606+
const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_1101, __wasm_bindgen_func_elem_1102);
3607+
return addHeapObject(ret);
3608+
};
35803609
imports.wbg.__wbindgen_cast_4625c577ab2ec9ee = function(arg0) {
35813610
// Cast intrinsic for `U64 -> Externref`.
35823611
const ret = BigInt.asUintN(64, arg0);
35833612
return addHeapObject(ret);
35843613
};
3585-
imports.wbg.__wbindgen_cast_62423eba93f4d670 = function(arg0, arg1) {
3586-
// Cast intrinsic for `Closure(Closure { dtor_idx: 166, function: Function { arguments: [Externref], shim_idx: 167, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3587-
const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_1091, __wasm_bindgen_func_elem_1092);
3588-
return addHeapObject(ret);
3589-
};
3590-
imports.wbg.__wbindgen_cast_98c349af0503c7f4 = function(arg0, arg1) {
3591-
// Cast intrinsic for `Closure(Closure { dtor_idx: 166, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 167, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3592-
const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_1091, __wasm_bindgen_func_elem_1092);
3593-
return addHeapObject(ret);
3594-
};
35953614
imports.wbg.__wbindgen_cast_d6cd19b81560fd6e = function(arg0) {
35963615
// Cast intrinsic for `F64 -> Externref`.
35973616
const ret = arg0;
6.89 KB
Binary file not shown.

packages/wasm-threaded/pkg/ifc-lite_bg.wasm.d.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export const ifcapi_scanEntityIndexShard: (a: number, b: number, c: number, d: n
113113
export const ifcapi_scanGeometryEntitiesFast: (a: number, b: number, c: number) => number;
114114
export const ifcapi_scanRelevantEntitiesFastBytes: (a: number, b: number, c: number) => number;
115115
export const ifcapi_setEntityIndex: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void;
116+
export const ifcapi_setMergeLayers: (a: number, b: number) => void;
116117
export const ifcapi_version: (a: number, b: number) => void;
117118
export const instancedata_color: (a: number, b: number) => void;
118119
export const instancedata_expressId: (a: number) => number;
@@ -225,9 +226,9 @@ export const wbg_rayon_poolbuilder_build: (a: number) => void;
225226
export const wbg_rayon_poolbuilder_numThreads: (a: number) => number;
226227
export const wbg_rayon_poolbuilder_receiver: (a: number) => number;
227228
export const wbg_rayon_start_worker: (a: number) => void;
228-
export const __wasm_bindgen_func_elem_1092: (a: number, b: number, c: number) => void;
229-
export const __wasm_bindgen_func_elem_1091: (a: number, b: number) => void;
230-
export const __wasm_bindgen_func_elem_1363: (a: number, b: number, c: number, d: number) => void;
229+
export const __wasm_bindgen_func_elem_1102: (a: number, b: number, c: number) => void;
230+
export const __wasm_bindgen_func_elem_1101: (a: number, b: number) => void;
231+
export const __wasm_bindgen_func_elem_1373: (a: number, b: number, c: number, d: number) => void;
231232
export const memory: WebAssembly.Memory;
232233
export const __wbindgen_export: (a: number) => void;
233234
export const __wbindgen_export2: (a: number, b: number, c: number) => void;

packages/wasm-threaded/pkg/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"IFC-Lite Contributors"
66
],
77
"description": "WebAssembly bindings for IFC-Lite",
8-
"version": "2.3.0",
8+
"version": "2.4.0",
99
"license": "MPL-2.0",
1010
"repository": {
1111
"type": "git",

packages/wasm/pkg/ifc-lite_bg.wasm

391 Bytes
Binary file not shown.

packages/wasm/pkg/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"IFC-Lite Contributors"
66
],
77
"description": "WebAssembly bindings for IFC-Lite",
8-
"version": "2.3.0",
8+
"version": "2.4.0",
99
"license": "MPL-2.0",
1010
"repository": {
1111
"type": "git",

rust/geometry/src/router/voids.rs

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,7 +1799,21 @@ impl GeometryRouter {
17991799
}
18001800

18011801
/// Test if a triangle intersects an axis-aligned bounding box using Separating Axis Theorem (SAT)
1802-
/// Returns true if triangle and box intersect, false if they are separated
1802+
/// Returns true if triangle and box intersect, false if they are separated.
1803+
///
1804+
/// All separation tests use a small `SAT_EPSILON` slack so that a triangle
1805+
/// **lying exactly on a box face** (e.g. an extruded wall's outer face
1806+
/// that is coplanar with the opening AABB's `max.x` face after the opening
1807+
/// has been extended through the wall thickness) is reported as
1808+
/// intersecting and gets routed into the actual clipping path. Without
1809+
/// this slack, FP rounding can produce a tiny gap (the wall mesh is
1810+
/// stored in f32 and re-promoted to f64 here, while the opening box is
1811+
/// computed in pure f64) that the strict `<` reads as a separation — and
1812+
/// the wall's outer face survives un-clipped, leaving the wall solid
1813+
/// around its opening (issue #584 / Smiley-West balconies, follow-up:
1814+
/// the per-axis 1e-6 epsilon was correct for the box-axis tests but
1815+
/// undersized for the triangle-plane test, which uses an un-normalized
1816+
/// `triangle_normal` whose magnitude scales with triangle area).
18031817
fn triangle_intersects_box(
18041818
&self,
18051819
v0: &Point3<f64>,
@@ -1810,6 +1824,13 @@ impl GeometryRouter {
18101824
) -> bool {
18111825
use nalgebra::Vector3;
18121826

1827+
/// Float slack for SAT separation tests (1 micrometre at the IFC's
1828+
/// length unit). Big enough to absorb double-precision rounding
1829+
/// (`v.z - box_center.z` vs `(box_max.z - box_min.z) * 0.5`) on
1830+
/// box-coplanar triangles, small enough to not pull genuinely
1831+
/// separated triangles into the clipper.
1832+
const SAT_EPSILON: f64 = 1e-6;
1833+
18131834
// Box center and half-extents
18141835
let box_center = Point3::new(
18151836
(box_min.x + box_max.x) * 0.5,
@@ -1850,7 +1871,7 @@ impl GeometryRouter {
18501871
let tri_max = p0.max(p1).max(p2);
18511872
let box_extent = box_half_extents[axis_idx];
18521873

1853-
if tri_max < -box_extent || tri_min > box_extent {
1874+
if tri_max < -box_extent - SAT_EPSILON || tri_min > box_extent + SAT_EPSILON {
18541875
return false; // Separated on this axis
18551876
}
18561877
}
@@ -1871,7 +1892,21 @@ impl GeometryRouter {
18711892
box_projection += box_half_extents[i] * triangle_normal.dot(&axis).abs();
18721893
}
18731894

1874-
if triangle_offset.abs() > box_projection {
1895+
// Normalize the per-axis epsilon by the triangle-normal magnitude.
1896+
//
1897+
// `triangle_normal` is the un-normalized cross product `e0 × e2`, so
1898+
// `|triangle_normal| ≈ 2 * triangle_area`. Both `triangle_offset` and
1899+
// `box_projection` scale linearly with that magnitude, but the
1900+
// physical-space rounding error a "near-coplanar" face needs to absorb
1901+
// does NOT scale with triangle area. Without scaling SAT_EPSILON, a
1902+
// tall/wide wall face sitting ~3e-7 m outside the opening box (well
1903+
// within the f32 → f64 round-trip slop introduced by the mesh
1904+
// pipeline) becomes a separation gap of ~1.7e-6 in projection units,
1905+
// which a fixed 1e-6 epsilon misses — leaving the wall's outer face
1906+
// un-clipped (Smiley-West uncut walls, follow-up to #584).
1907+
let normal_magnitude = triangle_normal.norm();
1908+
let t2_epsilon = SAT_EPSILON * normal_magnitude.max(1.0);
1909+
if triangle_offset.abs() > box_projection + t2_epsilon {
18751910
return false; // Separated by triangle plane
18761911
}
18771912

@@ -1909,7 +1944,9 @@ impl GeometryRouter {
19091944
box_half_extents[i] * axis_normalized.dot(&box_axis_vec).abs();
19101945
}
19111946

1912-
if tri_max < -box_projection || tri_min > box_projection {
1947+
if tri_max < -box_projection - SAT_EPSILON
1948+
|| tri_min > box_projection + SAT_EPSILON
1949+
{
19131950
return false; // Separated on this axis
19141951
}
19151952
}

0 commit comments

Comments
 (0)