Skip to content

Commit b8a8206

Browse files
authored
Route non-rectangular openings through CSG to prevent over-cutting (#575)
* fix(geometry): cut non-box openings with CSG instead of AABB clipping Fixes #547 "cutting voids sometimes misses the right shape". `classify_openings` used `vertex_count > 100` as the only signal for falling back from AABB clipping to CSG. Low-tessellation openings whose profile is NOT a rectangle — trapezoids, chamfered frames, beveled rectangles, coarse arcs — have <= 100 vertices and an axis-aligned extrusion direction, so they slipped into the `Rectangular` path and got cut as their axis-aligned bounding box. The bounding box reaches beyond the actual opening (e.g. a trapezoid's wide end extends past its narrow end), so the wall lost material where the opening did not exist — visible as oversized voids around some windows and doors. Add `mesh_fills_axis_aligned_box`: every vertex must sit on one of the AABB faces. Boxes pass (by definition); cylinders, arches, tilted boxes, trapezoids don't. `classify_openings` now runs this check on each representation item's world-space mesh and drops items that fail into `NonRectangular` (CSG) even when the vertex count is low. Per-item routing matters because a single `IfcOpeningElement` can carry several items (a box main opening + a rounded sleeve, for instance); we classify each item independently rather than all-or-nothing on the merged mesh. Ships a regression test with a trapezoidal `IfcArbitraryClosedProfileDef` opening that checks the wall retains boundary vertices at the trapezoid's narrow edge — vertices an AABB cut would have removed. * chore(wasm): rebuild ifc-lite.wasm with non-box opening CSG fix Regenerate packages/wasm/pkg/ so the viewer picks up the `classify_openings` fix from the previous commit. Source changes only — no further code changes. * fix(geometry): accept tessellated boxes in mesh_fills_axis_aligned_box Addresses Codex P1 review comment on PR: the per-vertex "every coordinate at an AABB corner" check rejected axis-aligned box openings that happened to carry extra collinear vertices on their faces or edges (e.g. a rectangular IfcArbitraryClosedProfileDef modelled with polyline midpoints). Those openings got misrouted to the CSG path, and `apply_void_context` caps CSG at `MAX_CSG_OPERATIONS = 10` — so models with many such windows left later openings silently uncut. Switch the check from "every vertex at an AABB corner" to "every triangle lies flat on an AABB face". A triangle lies on a face when all three vertices share the same min or max value on some axis. Boxes pass by construction; tessellated boxes pass because the extra vertices still sit on the face plane. Cylinders, arches, trapezoids, and rotated boxes still fail because their side triangles span the interior. Adds a regression test (`many_tessellated_box_openings_are_all_cut`) that builds a wall with 15 tessellated-rectangle openings and asserts every opening produced a hole — the test fails on the old strict check because openings 11..=14 never got cut. * chore(wasm): rebuild ifc-lite.wasm with tessellated-box classification Regenerate packages/wasm/pkg/ so the viewer picks up the relaxed box detector. Source changes only. * fix(geometry): reject trapezoid extrusions in is_rectangular_box_mesh The discriminator added in #640 (`is_rectangular_box_mesh`) returns true for a trapezoid extrusion. After anti-parallel merging, a trapezoid has exactly 3 face-normal axes (front/back, top/bottom, slanted-sides which merge into one), but two of those axes are not perpendicular. The AABB cutter then over-cuts the host wall — exactly the regression #547 describes for low-tessellation non-rectangular openings. Add a mutual-orthogonality check between the 3 axes (dot tolerance 0.02 matching the existing 0.98 anti-parallel merge tolerance). Trapezoids and other non-orthogonal 3-axis shapes now route to NonRectangular CSG. Tests added (in addition to the end-to-end regression in csg_void_test): - `test_rectangular_box_detector_rejects_trapezoid_extrusion` — direct unit test on a hand-built trapezoid mesh - `test_rectangular_box_detector_accepts_rotated_box` — confirms the orthogonality check doesn't reject 45°-rotated boxes (which still have 3 mutually orthogonal face-normal axes) Rebuild WASM artifacts (single-thread 1080 KB, threaded 1139 KB; both under their 1100/1300 KB budgets). Closes #547.
1 parent 85e015e commit b8a8206

5 files changed

Lines changed: 394 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ifc-lite/wasm": patch
3+
---
4+
5+
Add regression tests for non-box `IfcOpeningElement` classification (#547). PR #640 already routes low-tessellation non-rectangular openings (trapezoids, chamfered rectangles, beveled windows, coarse arcs) through CSG via `is_rectangular_box_mesh` + `infer_opening_frame`. This change adds end-to-end coverage that loads inline IFC fixtures and asserts the cut respects the actual opening profile (trapezoid narrow-edge boundary vertices appear in the voided wall, and many tessellated-box openings on a single wall are all cut without the CSG-budget cap silently dropping any).
154 Bytes
Binary file not shown.

packages/wasm/pkg/ifc-lite_bg.wasm

154 Bytes
Binary file not shown.

rust/geometry/src/router/voids.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,21 @@ fn is_rectangular_box_mesh(mesh: &Mesh) -> bool {
388388
return false;
389389
}
390390

391+
// The 3 distinct face normals must be mutually orthogonal — otherwise a
392+
// shape like a trapezoid extrusion (front/back + top/bottom + two slanted
393+
// sides whose normals are anti-parallel and merge into one axis) would
394+
// pass with 3 "axes" but not actually be a box. A trapezoid's slanted
395+
// axis is not perpendicular to the top/bottom axis. Tolerance 0.02 rad
396+
// matches the 0.98 dot tolerance used above for anti-parallel merging.
397+
const ORTHOGONAL_DOT_TOL: f64 = 0.02;
398+
for i in 0..3 {
399+
for j in (i + 1)..3 {
400+
if axes[i].dot(&axes[j]).abs() > ORTHOGONAL_DOT_TOL {
401+
return false;
402+
}
403+
}
404+
}
405+
391406
// For each axis, the triangle offsets must cluster around exactly 2 values
392407
// (the two opposite faces of the box). More than 2 distinct planes means
393408
// the footprint is rectilinear-but-not-rectangular (e.g. an L-shape).
@@ -2696,6 +2711,75 @@ mod reveal_tests {
26962711
);
26972712
}
26982713

2714+
/// Regression for #547: a trapezoid extrusion has exactly 3 face-normal
2715+
/// axes after anti-parallel merging (front/back, top/bottom, and the two
2716+
/// slanted sides which merge into one axis), but two of those axes are
2717+
/// not perpendicular. Without an orthogonality check the detector would
2718+
/// classify it as a box and the AABB cutter would over-cut the host wall.
2719+
#[test]
2720+
fn test_rectangular_box_detector_rejects_trapezoid_extrusion() {
2721+
// Trapezoid extruded along +Y: narrow at z=0 (x ∈ [-0.3, 0.3]),
2722+
// wide at z=2 (x ∈ [-0.5, 0.5]), thickness 0.6 in y.
2723+
let mut positions: Vec<f32> = Vec::new();
2724+
let mut indices: Vec<u32> = Vec::new();
2725+
let push_v = |positions: &mut Vec<f32>, x: f32, y: f32, z: f32| {
2726+
positions.extend_from_slice(&[x, y, z]);
2727+
};
2728+
// 8 corners: 4 of trapezoid at y=0, 4 at y=0.6.
2729+
// Order: bl, br, tr, tl on each face (b=bottom narrow, t=top wide).
2730+
push_v(&mut positions, -0.3, 0.0, 0.0); // 0
2731+
push_v(&mut positions, 0.3, 0.0, 0.0); // 1
2732+
push_v(&mut positions, 0.5, 0.0, 2.0); // 2
2733+
push_v(&mut positions, -0.5, 0.0, 2.0); // 3
2734+
push_v(&mut positions, -0.3, 0.6, 0.0); // 4
2735+
push_v(&mut positions, 0.3, 0.6, 0.0); // 5
2736+
push_v(&mut positions, 0.5, 0.6, 2.0); // 6
2737+
push_v(&mut positions, -0.5, 0.6, 2.0); // 7
2738+
// Front (y=0): 0,1,2 + 0,2,3
2739+
indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]);
2740+
// Back (y=0.6): 5,4,7 + 5,7,6
2741+
indices.extend_from_slice(&[5, 4, 7, 5, 7, 6]);
2742+
// Bottom narrow (z=0): 4,5,1 + 4,1,0
2743+
indices.extend_from_slice(&[4, 5, 1, 4, 1, 0]);
2744+
// Top wide (z=2): 3,2,6 + 3,6,7
2745+
indices.extend_from_slice(&[3, 2, 6, 3, 6, 7]);
2746+
// Right slanted: 1,5,6 + 1,6,2
2747+
indices.extend_from_slice(&[1, 5, 6, 1, 6, 2]);
2748+
// Left slanted: 4,0,3 + 4,3,7
2749+
indices.extend_from_slice(&[4, 0, 3, 4, 3, 7]);
2750+
2751+
let mut mesh = Mesh::new();
2752+
mesh.positions = positions;
2753+
mesh.indices = indices;
2754+
assert!(
2755+
!is_rectangular_box_mesh(&mesh),
2756+
"trapezoid extrusion must be rejected — its slanted-side axis is \
2757+
not perpendicular to the top/bottom axis, so the AABB cutter would \
2758+
over-cut the host"
2759+
);
2760+
}
2761+
2762+
/// A box rotated 45° around Z should still be classified as a box: its
2763+
/// three face-normal axes are mutually orthogonal even though none align
2764+
/// with world axes. The diagonal cutter then handles the rotation.
2765+
#[test]
2766+
fn test_rectangular_box_detector_accepts_rotated_box() {
2767+
let opening = make_framed_box_mesh(
2768+
Point3::new(0.0, 0.0, 0.0),
2769+
Vector3::new(0.7071067811865476, 0.7071067811865476, 0.0),
2770+
Vector3::new(-0.7071067811865476, 0.7071067811865476, 0.0),
2771+
Vector3::new(0.0, 0.0, 1.0),
2772+
(-0.15, 0.15),
2773+
(-1.0, 1.0),
2774+
(0.0, 2.0),
2775+
);
2776+
assert!(
2777+
is_rectangular_box_mesh(&opening),
2778+
"axis-rotated boxes must still be detected — rotation alone does \
2779+
not make them non-rectangular"
2780+
);
2781+
}
2782+
26992783
#[test]
27002784
fn test_infers_sloped_brep_opening_frame() {
27012785
// Roof openings exported as BReps do not expose an extrusion direction.

0 commit comments

Comments
 (0)