Skip to content

Commit b6e83d3

Browse files
authored
fix(geometry): IfcBooleanClippingResult on walls with voids (#635) (#648)
1 parent 9433462 commit b6e83d3

15 files changed

Lines changed: 1751 additions & 158 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@ifc-lite/wasm": patch
3+
---
4+
5+
Fix `IfcBooleanClippingResult` on walls clipped by `IfcPolygonalBoundedHalfSpace` (issue #635).
6+
7+
Three related fixes that together restore correct geometry on walls whose body is a chained `IfcBooleanClippingResult`:
8+
9+
1. **Round-window voids reach the post-clip mesh.** The `IfcOpeningElement` cut path now runs against the boolean-clipped wall mesh rather than the un-clipped extrusion, so windows and doors are subtracted from the actual visible wall body.
10+
2. **Polygonal-bounded half-space orientation.** The cutter prism is built by extruding the polygon along Position's Z-axis (per the IFC spec) instead of along the slope plane normal — gable walls #60012 and #67828 in AC20-FZK-Haus now narrow to a peak and span the full wall length at the bottom (was: inverted, point-down).
11+
3. **Chained polygonal half-space clips compose correctly.** When two `IfcPolygonalBoundedHalfSpace` cuts are stacked (one per gable side), the cutter prisms are now MERGED into a single mesh and applied in ONE BSP CSG op. Previously the first cut's output exceeded `MAX_CSG_POLYGONS_PER_MESH`, causing the second cut to silently drop and leaving a flat horizontal cap at the gable apex.
12+
13+
Round-window opening profiles are also simplified before triangulation so AC20-style 36-segment circles fit under the CSG polygon budget instead of falling back to a square hole. CSG kernel diagnostics (`take_failures`) now surface every silent skip — including the `PolygonalBoundedHalfSpaceFallback` path — so callers can warn on geometry loss.

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3594,11 +3594,6 @@ function __wbg_get_imports(memory) {
35943594
imports.wbg.__wbg_warn_6e567d0d926ff881 = function(arg0) {
35953595
console.warn(getObject(arg0));
35963596
};
3597-
imports.wbg.__wbindgen_cast_059fe8fea69d1ac8 = function(arg0, arg1) {
3598-
// Cast intrinsic for `Closure(Closure { dtor_idx: 182, function: Function { arguments: [Externref], shim_idx: 183, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3599-
const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_1161, __wasm_bindgen_func_elem_1162);
3600-
return addHeapObject(ret);
3601-
};
36023597
imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) {
36033598
// Cast intrinsic for `Ref(String) -> Externref`.
36043599
const ret = getStringFromWasm0(arg0, arg1);
@@ -3609,8 +3604,13 @@ function __wbg_get_imports(memory) {
36093604
const ret = BigInt.asUintN(64, arg0);
36103605
return addHeapObject(ret);
36113606
};
3612-
imports.wbg.__wbindgen_cast_8408bf1a4d6b6c8b = function(arg0, arg1) {
3613-
// Cast intrinsic for `Closure(Closure { dtor_idx: 182, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 183, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3607+
imports.wbg.__wbindgen_cast_5981c93f1c3e6401 = function(arg0, arg1) {
3608+
// Cast intrinsic for `Closure(Closure { dtor_idx: 181, function: Function { arguments: [Externref], shim_idx: 182, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3609+
const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_1161, __wasm_bindgen_func_elem_1162);
3610+
return addHeapObject(ret);
3611+
};
3612+
imports.wbg.__wbindgen_cast_c66ca3e1e2ec9281 = function(arg0, arg1) {
3613+
// Cast intrinsic for `Closure(Closure { dtor_idx: 181, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 182, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
36143614
const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_1161, __wasm_bindgen_func_elem_1162);
36153615
return addHeapObject(ret);
36163616
};
-43.8 KB
Binary file not shown.

packages/wasm/pkg/.npmignore

Lines changed: 0 additions & 1 deletion
This file was deleted.

packages/wasm/pkg/ifc-lite.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3375,8 +3375,8 @@ function __wbg_get_imports() {
33753375
const ret = BigInt.asUintN(64, arg0);
33763376
return addHeapObject(ret);
33773377
};
3378-
imports.wbg.__wbindgen_cast_a7f9b7b12781c1bc = function(arg0, arg1) {
3379-
// Cast intrinsic for `Closure(Closure { dtor_idx: 169, function: Function { arguments: [Externref], shim_idx: 170, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3378+
imports.wbg.__wbindgen_cast_87cdb333eb97349f = function(arg0, arg1) {
3379+
// Cast intrinsic for `Closure(Closure { dtor_idx: 168, function: Function { arguments: [Externref], shim_idx: 169, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
33803380
const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_1251, __wasm_bindgen_func_elem_1252);
33813381
return addHeapObject(ret);
33823382
};

packages/wasm/pkg/ifc-lite_bg.wasm

-43.8 KB
Binary file not shown.

rust/geometry/src/csg.rs

Lines changed: 125 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,17 @@ impl Triangle {
113113

114114
/// Maximum polygon count for either operand in a csgrs boolean operation.
115115
///
116-
/// Rectangular solids are 12 triangles, so this still allows the simple box-like
117-
/// boolean cases we expect while avoiding the complex BSP trees that can overflow
118-
/// the browser's native call stack in WASM.
119-
const MAX_CSG_POLYGONS_PER_MESH: usize = 24;
116+
/// Rectangular solids are 12 triangles. A 16-segment circular prism — the
117+
/// downsampled form of a round-window opening (issue #635) — is 60
118+
/// triangles, and AC20-FZK-Haus packs two such prisms (outer + recessed)
119+
/// into a single opening element, totalling ~120 triangles for the cut.
120+
/// This budget accommodates that combined opening and the wall mesh
121+
/// without letting the BSP tree explode: 128 is the upper bound past
122+
/// which BSP CSG performance starts to degrade noticeably and the WASM
123+
/// browser stack is at risk.
124+
///
125+
/// Do NOT raise this above 128.
126+
const MAX_CSG_POLYGONS_PER_MESH: usize = 128;
120127
/// Maximum combined polygon count for CSG operations.
121128
const MAX_CSG_POLYGONS: usize = MAX_CSG_POLYGONS_PER_MESH * 2;
122129

@@ -748,6 +755,48 @@ impl ClippingProcessor {
748755
);
749756
return Ok(host_mesh.clone());
750757
}
758+
// Defensive: Manifold has been observed (Linux x86_64 CI,
759+
// AC20-FZK-Haus gable walls #60012/#67828) to return an
760+
// implausibly small result — e.g. 1 triangle from a
761+
// 12-triangle box host clipped by a polygonal-bounded
762+
// half-space prism that does NOT fully contain the host.
763+
// macOS aarch64 produces the expected pentagon on the
764+
// same input, so this is a cross-platform Manifold
765+
// determinism issue. When we detect a clearly-truncated
766+
// result, re-run the same op through the legacy BSP
767+
// path and keep whichever output looks like a real
768+
// clip. See `looks_degenerate` for the heuristic.
769+
if Self::manifold_result_looks_degenerate(host_mesh, &result) {
770+
let host_tris = host_mesh.indices.len() / 3;
771+
let result_tris = result.indices.len() / 3;
772+
eprintln!(
773+
"[manifold-csg] difference result looks degenerate \
774+
(host {} tris -> result {} tris); retrying via BSP fallback",
775+
host_tris, result_tris,
776+
);
777+
if let Some(bsp_result) = self.try_bsp_difference(host_mesh, opening_mesh) {
778+
if !Self::manifold_result_looks_degenerate(host_mesh, &bsp_result) {
779+
self.record_failure(
780+
BoolOp::Difference,
781+
BoolFailureReason::ManifoldOutputDegenerate {
782+
host_tris,
783+
result_tris,
784+
},
785+
);
786+
return Ok(bsp_result);
787+
}
788+
}
789+
// BSP also failed or produced suspicious output —
790+
// record but keep Manifold's result (better than
791+
// un-cut, in many cases).
792+
self.record_failure(
793+
BoolOp::Difference,
794+
BoolFailureReason::ManifoldOutputDegenerate {
795+
host_tris,
796+
result_tris,
797+
},
798+
);
799+
}
751800
return Ok(result);
752801
}
753802
Err(reason) => {
@@ -1271,6 +1320,71 @@ impl ClippingProcessor {
12711320
}
12721321
}
12731322

1323+
/// Heuristic: does this look like a botched CSG difference?
1324+
///
1325+
/// Detects the Linux-specific Manifold pathology where a wall body
1326+
/// clipped by an `IfcPolygonalBoundedHalfSpace` prism collapses to a
1327+
/// near-empty result (e.g. 1 triangle from a 12-triangle host box).
1328+
/// macOS aarch64 produces the full pentagon on identical input, so
1329+
/// this is a kernel-determinism issue, not a malformed cutter.
1330+
///
1331+
/// Rules:
1332+
/// * An empty result is a legit outcome (cutter contains host) —
1333+
/// NOT degenerate.
1334+
/// * A closed-volume result needs at least 4 triangles. Anything
1335+
/// below that is structurally broken.
1336+
/// * For hosts with >= 12 triangles (typical IFC solid input), the
1337+
/// output should retain at least 25 % of the host's triangle
1338+
/// count when the cutter is partial.
1339+
#[cfg_attr(not(feature = "manifold-csg"), allow(dead_code))]
1340+
fn manifold_result_looks_degenerate(host: &Mesh, result: &Mesh) -> bool {
1341+
let result_tris = result.indices.len() / 3;
1342+
if result_tris == 0 {
1343+
return false;
1344+
}
1345+
if result_tris < 4 {
1346+
return true;
1347+
}
1348+
let host_tris = host.indices.len() / 3;
1349+
if host_tris >= 12 && result_tris * 4 < host_tris {
1350+
return true;
1351+
}
1352+
false
1353+
}
1354+
1355+
/// Run `host - opening` through the legacy in-tree BSP CSG kernel.
1356+
/// Returns `None` if the BSP path can't accept the inputs (operands
1357+
/// past the per-mesh polygon cap, degenerate polygon extraction,
1358+
/// etc.) — caller falls back to keeping the Manifold output.
1359+
///
1360+
/// Used as a safety net under `manifold-csg` when Manifold's output
1361+
/// looks structurally broken (see [`Self::manifold_result_looks_degenerate`]).
1362+
/// The BSP path is more deterministic across OS/arch combos at the
1363+
/// cost of a hard 128-polygon-per-mesh cap.
1364+
#[cfg_attr(not(feature = "manifold-csg"), allow(dead_code))]
1365+
fn try_bsp_difference(&self, host_mesh: &Mesh, opening_mesh: &Mesh) -> Option<Mesh> {
1366+
let host_polys = Self::mesh_to_polygons(host_mesh);
1367+
let opening_polys = Self::mesh_to_polygons(opening_mesh);
1368+
if host_polys.is_empty() || opening_polys.is_empty() {
1369+
return None;
1370+
}
1371+
if !Self::can_run_csg_operation(host_polys.len(), opening_polys.len()) {
1372+
return None;
1373+
}
1374+
let result_polys = crate::bsp_csg::difference(host_polys, opening_polys);
1375+
match Self::polygons_to_mesh(&result_polys) {
1376+
Ok(mesh) => {
1377+
let cleaned = Self::remove_degenerate_triangles(&mesh, host_mesh);
1378+
if cleaned.is_empty() {
1379+
None
1380+
} else {
1381+
Some(cleaned)
1382+
}
1383+
}
1384+
Err(_) => None,
1385+
}
1386+
}
1387+
12741388
/// Validate mesh for common issues
12751389
fn validate_mesh(&self, mesh: &Mesh) -> bool {
12761390
// Check for NaN/Inf in positions
@@ -1589,15 +1703,19 @@ mod tests {
15891703

15901704
#[test]
15911705
fn test_csg_operation_guard_rejects_complex_operands() {
1706+
// Build a mesh with > MAX_CSG_POLYGONS_PER_MESH triangles. A box is 12
1707+
// tris, so 12 stacked boxes = 144 tris, comfortably above the
1708+
// 128-poly budget set for issue #635 round-window CSG support.
15921709
let box_mesh = aabb_to_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
15931710
let mut complex_mesh = Mesh::new();
1594-
complex_mesh.merge(&box_mesh);
1595-
complex_mesh.merge(&box_mesh);
1596-
complex_mesh.merge(&box_mesh);
1711+
for _ in 0..12 {
1712+
complex_mesh.merge(&box_mesh);
1713+
}
15971714

15981715
let polys_complex = ClippingProcessor::mesh_to_polygons(&complex_mesh);
15991716
let polys_box = ClippingProcessor::mesh_to_polygons(&box_mesh);
16001717

1718+
assert!(polys_complex.len() > MAX_CSG_POLYGONS_PER_MESH);
16011719
assert!(!ClippingProcessor::can_run_csg_operation(polys_complex.len(), polys_box.len()));
16021720
}
16031721
}

rust/geometry/src/diagnostics.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ pub enum BoolFailureReason {
9898
PolygonalBoundedHalfSpaceFallback,
9999
/// `IfcBooleanResult` operator string didn't match any known op.
100100
UnknownBooleanOperator(String),
101+
/// Manifold's `difference` returned output that is implausibly small
102+
/// relative to the host (e.g. 1 triangle from a 12-triangle box host
103+
/// when the cutter does not fully contain the host). Observed on
104+
/// Linux x86_64 for the AC20-FZK-Haus gable walls; macOS aarch64 on
105+
/// the same input produces the expected pentagon. The caller logged
106+
/// this and re-ran the same op through the legacy BSP path; the
107+
/// retained output (Manifold or BSP) depends on which one looked
108+
/// sane.
109+
ManifoldOutputDegenerate {
110+
host_tris: usize,
111+
result_tris: usize,
112+
},
101113
/// Catch-all for kernel-specific errors. Free-form because the legacy BSP
102114
/// returns `String` errors and Manifold (Sprint 2) will return its own.
103115
KernelError(String),
@@ -125,6 +137,13 @@ impl fmt::Display for BoolFailureReason {
125137
BoolFailureReason::UnknownBooleanOperator(op) => {
126138
write!(f, "unknown IfcBooleanResult operator '{op}'")
127139
}
140+
BoolFailureReason::ManifoldOutputDegenerate {
141+
host_tris,
142+
result_tris,
143+
} => write!(
144+
f,
145+
"Manifold difference returned implausibly small result ({result_tris} triangles from {host_tris}-triangle host) — fell back to BSP"
146+
),
128147
BoolFailureReason::KernelError(msg) => write!(f, "kernel error: {msg}"),
129148
}
130149
}

0 commit comments

Comments
 (0)