ifc_lite_geometry/router/voids/mod.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Void (opening) subtraction: 3D CSG, AABB clipping, and triangle-box intersection.
6
7use super::GeometryRouter;
8use crate::csg::ClippingProcessor;
9use crate::mesh::{SubMesh, SubMeshCollection};
10use crate::{Mesh, Point3, Result, TessellationQuality, Vector3};
11use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
12use nalgebra::Matrix3;
13use rustc_hash::FxHashMap;
14
15mod aabb_clip;
16mod bool2d_path;
17mod coaxial_union;
18mod geom;
19mod prism_cut;
20mod probe;
21mod synthesis;
22
23pub use bool2d_path::take_bool2d_stats;
24pub use prism_cut::{take_prism_defers, take_prism_stats};
25#[cfg(test)]
26mod reveal_tests;
27
28use geom::*;
29use sweep::{cut_changed_mesh, drop_faces_outside_host};
30mod sweep;
31
32/// Epsilon for normalizing direction vectors (guards against zero-length).
33const NORMALIZE_EPSILON: f64 = 1e-12;
34/// Minimum opening volume (m³) below which CSG is skipped (degenerate-void filter).
35/// 0.0001 m³ ≈ 0.1 litre — filters artefacts while allowing small real openings (e.g. sleeves).
36const MIN_OPENING_VOLUME: f64 = 0.0001;
37/// Fraction of pre-CSG triangles the result must retain. CSG outputs with fewer
38/// triangles than `pre_count / CSG_TRIANGLE_RETENTION_DIVISOR` are rejected as
39/// kernel blowups.
40const CSG_TRIANGLE_RETENTION_DIVISOR: usize = 4;
41/// Minimum triangle count for a valid CSG result.
42const MIN_VALID_TRIANGLES: usize = 4;
43/// Maximum wrapper depth when drilling through mapped/boolean items to find an extrusion.
44const MAX_EXTRUSION_EXTRACT_DEPTH: usize = 32;
45/// Per-axis AABB engulf slack shared by the batched, host-consumed, and
46/// rectangular-fallback engulf checks below, so the three guards can't drift
47/// apart on what counts as "engulfs the host".
48const ENGULF_TOLERANCE: f64 = 0.03;
49const ENGULF_TOLERANCE_F32: f32 = 0.03;
50
51/// Classification of an opening for void subtraction.
52#[derive(Clone)]
53enum OpeningType {
54 /// Rectangular opening with AABB clipping
55 /// Fields: (min_bounds, max_bounds, extrusion_direction)
56 Rectangular(Point3<f64>, Point3<f64>, Option<Vector3<f64>>),
57 /// Diagonal rectangular opening with mesh geometry and a full oriented frame.
58 /// The frame preserves roof-window roll, not just the penetration direction.
59 DiagonalRectangular(Mesh, OpeningFrame),
60 /// Non-rectangular opening (circular, arched, or floor openings with
61 /// rotated footprint). Uses full CSG subtraction with the actual mesh
62 /// geometry. The AABB + extrusion direction are kept so that callers can
63 /// fall back to a rectangular box cut when CSG can't run (issue #635 —
64 /// e.g. circular windows whose triangulated profile blows past
65 /// `MAX_CSG_POLYGONS_PER_MESH`).
66 NonRectangular(Mesh, Point3<f64>, Point3<f64>, Option<Vector3<f64>>),
67}
68
69/// World-space basis for an oriented rectangular opening.
70#[derive(Clone, Copy)]
71struct OpeningFrame {
72 depth: Vector3<f64>,
73 cross_a: Vector3<f64>,
74 cross_b: Vector3<f64>,
75}
76
77impl OpeningFrame {
78 fn from_depth(depth: Vector3<f64>) -> Option<Self> {
79 let depth = depth.try_normalize(NORMALIZE_EPSILON)?;
80 let seed = if depth.z.abs() < 0.9 {
81 Vector3::new(0.0, 0.0, 1.0)
82 } else {
83 Vector3::new(0.0, 1.0, 0.0)
84 };
85 let cross_a = seed.cross(&depth).try_normalize(NORMALIZE_EPSILON)?;
86 let cross_b = depth.cross(&cross_a).try_normalize(NORMALIZE_EPSILON)?;
87 Some(Self {
88 depth,
89 cross_a,
90 cross_b,
91 })
92 }
93
94 fn is_axis_aligned(&self) -> bool {
95 is_axis_aligned_direction(&self.depth)
96 && is_axis_aligned_direction(&self.cross_a)
97 && is_axis_aligned_direction(&self.cross_b)
98 }
99}
100
101/// Pre-computed per-element void subtraction data.
102///
103/// Building this is expensive: `classify_openings` re-runs `process_element`
104/// on each `IfcOpeningElement`, and clipping-plane extraction resolves the
105/// element's representation. Once built, it can be reused across every
106/// sub-mesh of the same element without re-doing any of that work, so the
107/// per-sub-mesh void path in
108/// [`GeometryRouter::process_element_with_submeshes_and_voids`] pays the
109/// classification cost once per element rather than once per sub-mesh.
110pub(super) struct VoidContext {
111 /// All classified openings. The diagonal-opening pass needs the raw list
112 /// (unmerged) so its per-item box rotation stays accurate.
113 openings: Vec<OpeningType>,
114 /// Rectangular openings merged into larger boxes to prevent O(2^N)
115 /// triangle growth when many adjacent openings tile a surface.
116 merged_openings: Vec<OpeningType>,
117 /// Parametric placement-frame cut data (host + reconciled opening boxes),
118 /// captured only when `rect_fast::param_enabled()`. Drives the analytic fast
119 /// path in `apply_void_context`; `None` defers to the exact kernel.
120 param: Option<ParamRectCut>,
121 /// 2D opening-subtraction data (host profile + projected opening footprints),
122 /// captured only when `bool2d_path::enabled()`. Drives the arbitrary-profile
123 /// 2D re-extrude fast path in `apply_void_context`, tried after the rect
124 /// parametric path; `None` defers to the exact kernel.
125 bool2d: Option<bool2d_path::Bool2dCut>,
126}
127
128impl VoidContext {
129 fn is_noop(&self) -> bool {
130 self.openings.is_empty()
131 }
132
133 /// True iff every cutter mesh is already in world coords (`origin == 0`). When
134 /// so AND the host is world-framed, `apply_void_context` can run the inner CSG
135 /// directly (the native / legacy fast path) without cloning the cutters; if any
136 /// cutter carries its own per-element origin it must be relativized first.
137 fn all_cutters_world_framed(&self) -> bool {
138 let world = |o: &OpeningType| match o {
139 OpeningType::Rectangular(..) => true,
140 OpeningType::DiagonalRectangular(m, _) => m.origin == [0.0, 0.0, 0.0],
141 OpeningType::NonRectangular(m, ..) => m.origin == [0.0, 0.0, 0.0],
142 };
143 self.openings.iter().all(world) && self.merged_openings.iter().all(world)
144 }
145
146 /// Return a copy of this context with every opening (raw + merged)
147 /// translated by `-origin`, i.e. moved from the world frame into the host's
148 /// per-element local frame. Used by [`GeometryRouter::apply_void_context`]
149 /// so the cutters share the host's local frame for the CSG — the missing
150 /// piece that previously made relativizing only the host silently drop cuts.
151 fn relativized_by(&self, origin: [f64; 3]) -> VoidContext {
152 VoidContext {
153 openings: self.openings.iter().map(|o| o.translated(origin)).collect(),
154 merged_openings: self
155 .merged_openings
156 .iter()
157 .map(|o| o.translated(origin))
158 .collect(),
159 // The parametric path runs in the OUTER apply_void_context on the
160 // non-relativized world mesh (it makes its own frame), so the
161 // relativized context never uses `param` — drop it.
162 param: None,
163 // Likewise the 2D path builds its own world frame from the parametrics
164 // and runs in the OUTER apply_void_context; the relativized context
165 // never uses it.
166 bool2d: None,
167 }
168 }
169
170 /// Oriented boxes of the REAL openings whose cutter mesh is MALFORMED —
171 /// self-intersecting tessellated voids carrying garbage "fin" vertices far
172 /// from the actual hole (a broken export). The kernel under-cuts such a
173 /// cutter, leaving a wall flap bridging the opening; these boxes mark the
174 /// region that MUST end up empty so a post-cut pass can drop the flap.
175 /// Empty when every cutter is well-formed, so clean hosts are untouched.
176 fn malformed_opening_boxes(&self) -> Vec<OpeningBox> {
177 self.openings
178 .iter()
179 .filter_map(|o| match o {
180 OpeningType::NonRectangular(m, ..) => opening_obb_if_malformed(m),
181 _ => None,
182 })
183 .collect()
184 }
185}
186
187/// An oriented box: world centre, orthonormal axes, half-extents along each.
188struct OpeningBox {
189 center: Vector3<f64>,
190 axes: [Vector3<f64>; 3],
191 half: [f64; 3],
192}
193
194impl OpeningBox {
195 /// The thinnest axis — the through-wall / penetration direction.
196 fn thin_axis(&self) -> usize {
197 (0..3)
198 .min_by(|&i, &j| self.half[i].partial_cmp(&self.half[j]).unwrap())
199 .unwrap()
200 }
201
202 /// A watertight box mesh for the real opening, EXTENDED by `extend` along the
203 /// thin (through-wall) axis so it fully penetrates the host, with positions
204 /// in the frame whose origin is `origin` (i.e. world − origin). Subtracting
205 /// this from the host carves a clean through-opening — see
206 /// [`recut_malformed_openings`].
207 fn extended_box_mesh(&self, origin: [f64; 3], extend: f64) -> Mesh {
208 let thin = self.thin_axis();
209 let mut half = self.half;
210 half[thin] += extend;
211 let corner = |sx: f64, sy: f64, sz: f64| -> Point3<f64> {
212 let w = self.center
213 + self.axes[0] * (sx * half[0])
214 + self.axes[1] * (sy * half[1])
215 + self.axes[2] * (sz * half[2]);
216 Point3::new(w.x - origin[0], w.y - origin[1], w.z - origin[2])
217 };
218 // `make_obb_mesh`'s canonical corner order (bit k -> axis k sign).
219 let c = [
220 corner(-1.0, -1.0, -1.0),
221 corner(1.0, -1.0, -1.0),
222 corner(1.0, 1.0, -1.0),
223 corner(-1.0, 1.0, -1.0),
224 corner(-1.0, -1.0, 1.0),
225 corner(1.0, -1.0, 1.0),
226 corner(1.0, 1.0, 1.0),
227 corner(-1.0, 1.0, 1.0),
228 ];
229 make_obb_mesh(&c)
230 }
231}
232
233/// Build a watertight box mesh from 8 corners in the canonical order (bit k ->
234/// axis k sign). Face winding mirrors `GeometryRouter::make_box_mesh`; normals
235/// are derived from the (oriented) geometry rather than hardcoded axes.
236fn make_obb_mesh(corners: &[Point3<f64>; 8]) -> Mesh {
237 let faces: [[usize; 4]; 6] = [
238 [0, 3, 2, 1],
239 [4, 5, 6, 7],
240 [0, 1, 5, 4],
241 [2, 3, 7, 6],
242 [0, 4, 7, 3],
243 [1, 2, 6, 5],
244 ];
245 let mut m = Mesh::with_capacity(24, 36);
246 for idx in &faces {
247 let a = corners[idx[0]];
248 let b = corners[idx[1]];
249 let cc = corners[idx[2]];
250 let nrm = (b - a)
251 .cross(&(cc - a))
252 .try_normalize(1.0e-12)
253 .unwrap_or_else(|| Vector3::new(0.0, 0.0, 1.0));
254 let base = m.vertex_count() as u32;
255 m.add_vertex(corners[idx[0]], nrm);
256 m.add_vertex(corners[idx[1]], nrm);
257 m.add_vertex(corners[idx[2]], nrm);
258 m.add_vertex(corners[idx[3]], nrm);
259 m.add_triangle(base, base + 1, base + 2);
260 m.add_triangle(base, base + 2, base + 3);
261 }
262 m
263}
264
265/// True if `m` welds (by position) to a CLOSED 2-manifold: every edge is shared
266/// by exactly two triangles — the topological signature of a valid solid. A
267/// clean opening box AND a watertight roof/gable prism (a tall cutter reaching
268/// hundreds of metres up to clip a wall to the roofline) both pass; only a
269/// self-intersecting / fin-laden GARBAGE cutter (the #1007 family) leaves
270/// boundary or non-manifold edges and fails. Conservative: a cutter with too
271/// few faces to bound a solid (e.g. a stray point cloud) is NOT closed, so it
272/// stays eligible for the malformed-cutter repair.
273fn cutter_is_closed_manifold(m: &Mesh) -> bool {
274 // A closed solid needs >= 4 triangles (a tetrahedron).
275 if m.indices.len() < 12 {
276 return false;
277 }
278 // Weld duplicated per-face vertices back together so shared edges are
279 // detectable; 10 µm is far below any real opening feature.
280 let w = m.welded_by_position(1.0e-5);
281 if w.indices.len() < 12 {
282 return false;
283 }
284 let mut edge_uses: FxHashMap<(u32, u32), u32> = FxHashMap::default();
285 for t in w.indices.chunks_exact(3) {
286 if t[0] == t[1] || t[1] == t[2] || t[0] == t[2] {
287 return false; // a degenerate triangle survived welding
288 }
289 for &(a, b) in &[(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
290 let key = if a < b { (a, b) } else { (b, a) };
291 *edge_uses.entry(key).or_insert(0) += 1;
292 }
293 }
294 edge_uses.values().all(|&c| c == 2)
295}
296
297/// Compute the clean oriented box of a cutter's REAL opening iff the cutter is
298/// MALFORMED (a far-flung garbage-vertex cluster). `None` for a well-formed
299/// cutter — clean hosts are never reshaped.
300///
301/// The real opening is a TIGHT vertex cluster; garbage "fins" sit far away (and
302/// a fin running ALONG a long wall stays inside the host AABB, so we cluster
303/// INTRINSICALLY, not by host containment). Robust per-axis median centre
304/// (garbage is a minority, so the median lands in the real box), sort vertices
305/// by distance, cut at the largest RATIO gap in the upper half. No clear gap ->
306/// not malformed -> `None`. Principal axes via covariance eigendecomposition
307/// (for a box the eigenvectors align with the edges).
308fn opening_obb_if_malformed(m: &Mesh) -> Option<OpeningBox> {
309 // A cutter that welds to a closed solid is well-formed by construction — its
310 // far vertices are STRUCTURAL (a watertight roof prism, not stray garbage),
311 // so the far-cluster heuristic below must never reshape it. This is what
312 // separates a legitimate gable/roof cut (whose top can sit ~900 m up) from
313 // the self-intersecting tessellated voids the repair targets.
314 if cutter_is_closed_manifold(m) {
315 return None;
316 }
317 let all: Vec<Vector3<f64>> = m
318 .positions
319 .chunks_exact(3)
320 .map(|p| {
321 Vector3::new(
322 p[0] as f64 + m.origin[0],
323 p[1] as f64 + m.origin[1],
324 p[2] as f64 + m.origin[2],
325 )
326 })
327 .collect();
328 if all.len() < 8 {
329 return None;
330 }
331 // Bail on any non-finite vertex so the partial_cmp sorts below cannot panic;
332 // a garbage cutter is not worth reshaping (file filters non-finite elsewhere).
333 if all.iter().any(|v| v.iter().any(|c| !c.is_finite())) {
334 return None;
335 }
336 let median_axis = |axis: usize| -> f64 {
337 let mut vals: Vec<f64> = all.iter().map(|v| v[axis]).collect();
338 vals.sort_by(f64::total_cmp);
339 vals[vals.len() / 2]
340 };
341 let med = Vector3::new(median_axis(0), median_axis(1), median_axis(2));
342 let mut dist: Vec<(f64, usize)> = all
343 .iter()
344 .enumerate()
345 .map(|(i, v)| ((v - med).norm(), i))
346 .collect();
347 dist.sort_by(|a, b| a.0.total_cmp(&b.0));
348 // The garbage "fins" of these broken cutters sit METRES from the opening
349 // (≈9 m here), far beyond any legitimate opening vertex (even a big garage
350 // door is ≲3 m). Detect malformity ONLY by an ABSOLUTE far cluster — a
351 // vertex `FAR_M` beyond the near cluster AND past a big jump. A clean opening
352 // (every vertex within its own footprint, distances uniformly close) never
353 // trips this, so it is never reshaped. Anything tighter risks over-cutting a
354 // well-formed opening, which is far worse than leaving a rare flap.
355 const FAR_M: f64 = 4.0;
356 let near_radius = dist[dist.len() / 2].0; // 50th-percentile distance
357 let mut split_at = dist.len();
358 let mut found = false;
359 for i in (dist.len() / 2)..(dist.len() - 1) {
360 let gap = dist[i + 1].0 - dist[i].0;
361 // a clear gap that lands the far points beyond FAR_M and >3x the near
362 // cluster — the bimodal near-opening / far-garbage signature.
363 if dist[i + 1].0 > FAR_M
364 && dist[i + 1].0 > 3.0 * near_radius.max(1.0e-3)
365 && gap > dist[i].0
366 {
367 split_at = i + 1;
368 found = true;
369 break;
370 }
371 }
372 if !found {
373 return None;
374 }
375 let inliers: Vec<Vector3<f64>> = dist[..split_at].iter().map(|(_, i)| all[*i]).collect();
376 if inliers.len() < 8 {
377 return None;
378 }
379 let n = inliers.len() as f64;
380 let mut c = Vector3::zeros();
381 for v in &inliers {
382 c += v;
383 }
384 c /= n;
385 let mut cov = Matrix3::zeros();
386 for v in &inliers {
387 let d = v - c;
388 cov += d * d.transpose();
389 }
390 cov /= n;
391 let eig = cov.symmetric_eigen();
392 let a0 = eig.eigenvectors.column(0).into_owned().try_normalize(1.0e-9)?;
393 let a1 = eig.eigenvectors.column(1).into_owned().try_normalize(1.0e-9)?;
394 let a2 = a0.cross(&a1).try_normalize(1.0e-9)?;
395 let axes = [a0, a1, a2];
396 let mut lo = [f64::MAX; 3];
397 let mut hi = [f64::MIN; 3];
398 for v in &inliers {
399 for k in 0..3 {
400 let t = v.dot(&axes[k]);
401 lo[k] = lo[k].min(t);
402 hi[k] = hi[k].max(t);
403 }
404 }
405 let half = [
406 (hi[0] - lo[0]) * 0.5,
407 (hi[1] - lo[1]) * 0.5,
408 (hi[2] - lo[2]) * 0.5,
409 ];
410 if half.iter().any(|&h| h < 1.0e-3) {
411 return None;
412 }
413 let mid = [
414 (hi[0] + lo[0]) * 0.5,
415 (hi[1] + lo[1]) * 0.5,
416 (hi[2] + lo[2]) * 0.5,
417 ];
418 let center = axes[0] * mid[0] + axes[1] * mid[1] + axes[2] * mid[2];
419 Some(OpeningBox { center, axes, half })
420}
421
422/// Repair the kernel's UNDER-cut of malformed (self-intersecting) void cutters —
423/// the #1007 "flap" where a wall triangle bridges the opening — by RE-CUTTING
424/// each malformed opening with a clean box.
425///
426/// The self-intersecting cutter leaves the host's original large wall-face
427/// triangles spanning the opening (and extending out to the wall edges). The
428/// correct repair is to subtract a clean box of the real opening: the exact
429/// kernel removes only the opening prism — taking the flap with it — while
430/// splitting and re-triangulating the wall AROUND the hole and forming the
431/// reveal faces. (A plain triangle drop would also delete the legitimate wall
432/// above/below the opening, since those large triangles merely overlap it.)
433///
434/// World-framed boxes are folded into the result's frame. `subtract_mesh`'s
435/// budget guard returns the host un-cut on any failure, so a hard case degrades
436/// to "flap remains", never an over-cut. A no-op when `boxes` is empty (every
437/// cutter well-formed) — clean hosts are untouched.
438fn recut_malformed_openings(result: &mut Mesh, boxes: &[OpeningBox]) {
439 if boxes.is_empty() || result.indices.is_empty() {
440 return;
441 }
442 let clipper = ClippingProcessor::new();
443 for bx in boxes {
444 // Extend 2 m past the opening along the thin axis so the box fully
445 // penetrates any normal wall (the subtract only removes box ∩ host).
446 let box_mesh = bx.extended_box_mesh(result.origin, 2.0);
447 if let Ok(cut) = clipper.subtract_mesh(result, &box_mesh) {
448 // A clean box can only remove the opening prism, so it never empties
449 // a real wall; ignore a degenerate empty result defensively.
450 if !cut.is_empty() {
451 let origin = result.origin;
452 *result = cut;
453 result.origin = origin;
454 }
455 }
456 }
457}
458
459/// Express a cutter mesh in the host's local frame: `result = position +
460/// mesh.origin - host_origin`, folded in f64 before the f32 store, with the
461/// result origin zeroed (the mesh now lives in the shared host frame).
462///
463/// Honouring the cutter's OWN `origin` keeps a per-element local-frame opening
464/// (the wasm default: small positions relative to the opening's AABB centre)
465/// PRECISE — it is never first rounded to absolute world f32 and then brought
466/// back near the host, so detail openings far from the global origin can't
467/// collapse on the coarse world grid (#1310 review). A world-framed cutter
468/// (`origin == 0` — the native and ≤100-vertex default) reduces to the plain
469/// `position - host_origin` and stays byte-identical.
470/// World-space AABB of a host mesh: its local `bounds()` with `mesh.origin`
471/// folded back in (world = origin + position). Folded in f64 so a
472/// georeferenced host (large origin) keeps as much precision as the f32
473/// diagnostic surface allows, rather than reporting near-zero local coords.
474fn world_host_bounds(mesh: &Mesh) -> ((f32, f32, f32), (f32, f32, f32)) {
475 let o = mesh.origin;
476 let (mn, mx) = mesh.bounds();
477 (
478 (
479 (mn.x as f64 + o[0]) as f32,
480 (mn.y as f64 + o[1]) as f32,
481 (mn.z as f64 + o[2]) as f32,
482 ),
483 (
484 (mx.x as f64 + o[0]) as f32,
485 (mx.y as f64 + o[1]) as f32,
486 (mx.z as f64 + o[2]) as f32,
487 ),
488 )
489}
490
491fn translate_cutter_mesh(mesh: &Mesh, host_origin: [f64; 3]) -> Mesh {
492 let o = mesh.origin;
493 let mut positions = Vec::with_capacity(mesh.positions.len());
494 for c in mesh.positions.chunks_exact(3) {
495 positions.push((c[0] as f64 + o[0] - host_origin[0]) as f32);
496 positions.push((c[1] as f64 + o[1] - host_origin[1]) as f32);
497 positions.push((c[2] as f64 + o[2] - host_origin[2]) as f32);
498 }
499 Mesh {
500 positions,
501 normals: mesh.normals.clone(),
502 indices: mesh.indices.clone(),
503 rtc_applied: mesh.rtc_applied,
504 origin: [0.0; 3],
505 instance_meta: None,
506 local_bounds: None,
507 local_to_world: None,
508 }
509}
510
511impl OpeningType {
512 /// Return a copy translated by `-origin` (world → host-local frame). Bounds
513 /// (`Point3`) shift; direction vectors and the oriented `OpeningFrame` are
514 /// translation-invariant and pass through unchanged.
515 fn translated(&self, origin: [f64; 3]) -> OpeningType {
516 let sub = |p: &Point3<f64>| Point3::new(p.x - origin[0], p.y - origin[1], p.z - origin[2]);
517 match self {
518 OpeningType::Rectangular(min, max, dir) => {
519 OpeningType::Rectangular(sub(min), sub(max), *dir)
520 }
521 OpeningType::DiagonalRectangular(mesh, frame) => {
522 OpeningType::DiagonalRectangular(translate_cutter_mesh(mesh, origin), *frame)
523 }
524 OpeningType::NonRectangular(mesh, min, max, dir) => {
525 OpeningType::NonRectangular(translate_cutter_mesh(mesh, origin), sub(min), sub(max), *dir)
526 }
527 }
528 }
529}
530
531/// EXACT parametric oriented box of a rectangular extrusion, in WORLD space.
532/// `r` columns are the orthonormal world axes (profile-X', profile-Y', extrude);
533/// `half` are the half-extents along those axes (XDim/2, YDim/2, Depth/2).
534/// Produced by [`GeometryRouter::parametric_rect_probe`].
535#[derive(Clone, Copy)]
536pub struct RectParam {
537 pub r: Matrix3<f64>,
538 pub center: Point3<f64>,
539 pub half: [f64; 3],
540}
541
542/// Captured parametric boxes for a host + its openings (all reconciled to their
543/// meshes and sharing the host frame), enabling the analytic placement-frame cut.
544/// Built in [`GeometryRouter::build_void_context`] only when `param_enabled()`.
545#[derive(Clone)]
546struct ParamRectCut {
547 host: RectParam,
548 openings: Vec<RectParam>,
549}
550
551impl GeometryRouter {
552 /// Tier-conditional minimum opening volume (m³) floor. Shared by the batch
553 /// admission gate and the sequential CSG path so the two can never diverge
554 /// (B11 / issue #976): the 0.1 L default filters legacy-BSP CSG artefacts,
555 /// but at High/Highest quality it relaxes to ~1e-9 m³ so genuine small
556 /// openings (bolt holes / sleeves in thin plates) still get cut instead of
557 /// being silently skipped.
558 #[inline]
559 fn min_opening_volume(quality: TessellationQuality) -> f64 {
560 match quality {
561 TessellationQuality::High | TessellationQuality::Highest => 1e-9,
562 _ => MIN_OPENING_VOLUME,
563 }
564 }
565
566 /// Process element with void subtraction (openings)
567 /// Process element with voids using optimized plane clipping
568 ///
569 /// This approach is more efficient than full 3D CSG for rectangular openings:
570 /// 1. Get chamfered wall mesh (preserves chamfered corners)
571 /// 2. For each opening, use optimized box cutting with internal face generation
572 /// 3. Apply any clipping operations (roof clips) from original representation
573 ///
574 /// Process an element with void subtraction (openings).
575 ///
576 /// This function handles three distinct cases for cutting openings:
577 ///
578 /// 1. **Floor/Slab openings** (vertical Z-extrusion): Uses CSG with actual mesh geometry
579 /// because the XY footprint may be rotated relative to the slab orientation.
580 ///
581 /// 2. **Wall openings** (horizontal X/Y-extrusion, axis-aligned): Uses AABB clipping
582 /// for fast, accurate cutting of rectangular openings.
583 ///
584 /// 3. **Diagonal wall openings**: Uses AABB clipping without internal face generation
585 /// to avoid rotation artifacts.
586 ///
587 /// Reveal faces (inner surfaces of the opening holes) are generated as a
588 /// post-clipping step for rectangular and diagonal openings. For diagonal
589 /// walls the geometry is computed in a rotated axis-aligned frame and
590 /// rotated back, giving correct results for any wall orientation.
591 #[inline]
592 pub fn process_element_with_voids(
593 &self,
594 element: &DecodedEntity,
595 decoder: &mut EntityDecoder,
596 void_index: &FxHashMap<u32, Vec<u32>>,
597 ) -> Result<Mesh> {
598 let opening_ids = match void_index.get(&element.id) {
599 Some(ids) if !ids.is_empty() => ids,
600 _ => {
601 return self.process_element(element, decoder);
602 }
603 };
604
605 let wall_mesh = match self.process_element(element, decoder) {
606 Ok(m) => m,
607 Err(_) => {
608 return self.process_element(element, decoder);
609 }
610 };
611
612 let mut voided = self.apply_voids_to_mesh(wall_mesh, element, opening_ids, decoder);
613 // Clean slivers the CSG cut can introduce at opening seams — same
614 // hygiene as the tessellation chokepoints (Mesh::clean_degenerate).
615 voided.clean_degenerate();
616 // Instancing: a void-cut mesh no longer reproduces its representation's
617 // canonical geometry, so it can never be shared. Drop any metadata that
618 // rode along from the (pre-cut) mapped item.
619 voided.instance_meta = None;
620 Ok(voided)
621 }
622
623 /// Apply opening subtraction and clipping planes to an already-built mesh.
624 ///
625 /// Shared entry point used by both the single-mesh path
626 /// ([`process_element_with_voids`]) and the per-sub-mesh path
627 /// ([`process_element_with_submeshes_and_voids`]). The incoming mesh is
628 /// expected to be in the same (world) coordinate space as the element —
629 /// i.e. placement already applied — because opening and clip geometry are
630 /// resolved in world coordinates.
631 ///
632 /// Returns the input mesh unchanged when it is invalid or when no
633 /// openings/clips apply, so callers never lose their input on a
634 /// degenerate opening set.
635 pub(super) fn apply_voids_to_mesh(
636 &self,
637 mesh: Mesh,
638 element: &DecodedEntity,
639 opening_ids: &[u32],
640 decoder: &mut EntityDecoder,
641 ) -> Mesh {
642 let ctx = self.build_void_context(element, opening_ids, decoder);
643 self.apply_void_context(mesh, &ctx, element.id)
644 }
645
646 /// Classify openings and extract clipping planes for an element.
647 ///
648 /// This is the expensive half of void subtraction — it decodes every
649 /// `IfcOpeningElement` (running `process_element` on each), classifies
650 /// them as rectangular / diagonal / non-rectangular, merges adjacent
651 /// rectangles, and transforms clipping planes to world space. The
652 /// output is reusable across every sub-mesh of the same element.
653 pub(super) fn build_void_context(
654 &self,
655 element: &DecodedEntity,
656 opening_ids: &[u32],
657 decoder: &mut EntityDecoder,
658 ) -> VoidContext {
659 // NOTE (issue #635): we no longer extract `IfcBooleanClippingResult`
660 // planes here. They are applied by `BooleanClippingProcessor::process`
661 // when building the input mesh (the wall-inversion fix in
662 // `processors/boolean.rs` makes the bounded-prism construction
663 // correct per IFC); re-applying them as unbounded planes discarded
664 // the polygonal bound and chopped off gable peaks (see
665 // `apply_void_context` for the full rationale).
666 let openings = self.classify_openings(element, opening_ids, decoder);
667 let merged_openings = Self::merge_rectangular_openings(&openings);
668
669 // PARAMETRIC fast-path capture (flag-gated, zero cost when off): probe the
670 // host + every opening for an exact rectangular box, reconcile each opening
671 // box against its mesh, and require all openings to share the host frame. Any
672 // miss -> `None` -> the host defers to the exact kernel.
673 let param = if crate::rect_fast::param_enabled() {
674 self.capture_param_rect(element, opening_ids, decoder)
675 } else {
676 None
677 };
678
679 // 2D opening-subtraction capture (flag-gated). Cheap when it misses
680 // (profile recovery only, no meshing), tried after the rect parametric
681 // path in `apply_void_context` so rect+rect hosts keep their existing
682 // output byte-identical and only the arbitrary-profile / rect-declined
683 // hosts reach the 2D re-extrude.
684 let bool2d = if bool2d_path::enabled() {
685 self.capture_bool2d(element, opening_ids, decoder)
686 } else {
687 None
688 };
689
690 VoidContext {
691 openings,
692 merged_openings,
693 param,
694 bool2d,
695 }
696 }
697
698 /// Build the parametric-cut data for a host + its openings, or `None` if any
699 /// precondition fails (host/opening not a clean rect extrusion, opening box
700 /// disagrees with its mesh, or an opening does not share the host frame).
701 fn capture_param_rect(
702 &self,
703 element: &DecodedEntity,
704 opening_ids: &[u32],
705 decoder: &mut EntityDecoder,
706 ) -> Option<ParamRectCut> {
707 if opening_ids.is_empty() {
708 return None;
709 }
710 let host = self.parametric_rect_probe(element, decoder)?;
711 let rt = host.r.transpose();
712 let mut openings = Vec::with_capacity(opening_ids.len());
713 for &oid in opening_ids {
714 let opening = decoder.decode_by_id(oid).ok()?;
715 if opening.ifc_type != IfcType::IfcOpeningElement {
716 return None;
717 }
718 // An opening may be a UNION OF RECTANGULAR PRISMS (Tekla multi-solid). Extract
719 // every box; each must share the host frame (signed permutation).
720 let op_boxes = self.parametric_rect_probe_all(&opening, decoder)?;
721 for op in &op_boxes {
722 signed_permutation_map(&(rt * op.r), 1.0e-3)?;
723 }
724 // Reconcile the boxes against the meshed opening by VOLUME: the boxes must
725 // account for the opening solid (catches non-rect / overlapping / partial parts).
726 if !self.opening_boxes_reconcile(&opening, &op_boxes, decoder) {
727 return None;
728 }
729 openings.extend(op_boxes);
730 }
731 Some(ParamRectCut { host, openings })
732 }
733
734 /// True iff the parametric `boxes` account for the actual meshed opening by VOLUME
735 /// (Σ box volume ≈ Σ mesh-shell volume within 3%). For a union of non-overlapping
736 /// rectangular prisms (the Tekla multi-solid opening) the mesh volume equals the
737 /// box-volume sum; a mismatch flags a non-rect / overlapping / partial part → defer.
738 fn opening_boxes_reconcile(
739 &self,
740 opening: &DecodedEntity,
741 boxes: &[RectParam],
742 decoder: &mut EntityDecoder,
743 ) -> bool {
744 if boxes.is_empty() {
745 return false;
746 }
747 let Ok(meshes) = self.get_opening_item_meshes_world(opening, decoder) else {
748 return false;
749 };
750 let mesh_vol: f64 = meshes.iter().map(|m| mesh_signed_volume(m).abs()).sum();
751 if mesh_vol < 1.0e-9 {
752 return false;
753 }
754 let box_vol: f64 = boxes
755 .iter()
756 .map(|b| 8.0 * b.half[0] * b.half[1] * b.half[2])
757 .sum();
758 let ratio = box_vol / mesh_vol;
759 (0.97..1.03).contains(&ratio)
760 }
761
762 /// Apply a pre-built `VoidContext` to a single mesh.
763 ///
764 /// This is the cheap per-mesh half of void subtraction: it re-reads the
765 /// mesh bounds (which differ per sub-mesh), extends rectangular openings
766 /// along their extrusion axis so they fully penetrate the mesh, then
767 /// subtracts every opening through the unified exact-kernel path (with the
768 /// per-opening #635 AABB fallback). All the classification work has
769 /// already been done in [`GeometryRouter::build_void_context`].
770 ///
771 /// `element_id` is the IFC product express ID of the host element. Any
772 /// `BoolFailure` recorded by the inner CSG kernel is attributed to that
773 /// product and stored on the router (drainable via
774 /// [`GeometryRouter::take_csg_failures`]). The router's failure log is
775 /// the only path failures reach the caller; `apply_void_context` itself
776 /// always returns the (possibly un-cut) mesh.
777 /// Apply a pre-built `VoidContext`, honouring the host's per-element local
778 /// frame.
779 ///
780 /// When local-frame precision is on, the host mesh arrives stored relative
781 /// to `mesh.origin` (small, f32-exact). The CSG must run with host AND
782 /// cutters in that SAME frame, or the cutters (resolved in world coords)
783 /// won't overlap the local host and every cut silently drops — the
784 /// 222692→190201 regression from the first attempt. This wrapper strips the
785 /// origin off the host (so the inner body works in a pure origin-0 local
786 /// frame with no origin-aware-merge surprises), relativizes the cutters by
787 /// the same origin, runs the CSG, then re-stamps the origin on the result so
788 /// `world = origin + position` holds for the renderer.
789 /// PARAMETRIC analytic fast path: subtract the openings as EXACT parametric boxes
790 /// in the host's own placement frame (where the rotated wall + windows are
791 /// axis-aligned), using the watertight cellular `rect_fast` cut, then rotate the
792 /// result back to world. Frame + extents come from the IFC parametrics (not the
793 /// mesh), so the cut is the analytic box-minus-boxes solid — ground-truth exact and
794 /// MORE correct than the exact kernel on engulfing-opening walls. Fires only on the
795 /// gated subset (`ctx.param` captured; host/opening reconciliation, shared-frame,
796 /// in-bounds, no-overlap, watertight self-check); any miss returns `None` → exact
797 /// kernel. Deterministic f64 → byte-identical native==wasm.
798 fn try_param_rect_cut(&self, mesh: &Mesh, ctx: &VoidContext) -> Option<Mesh> {
799 let param = ctx.param.as_ref()?;
800 let host = ¶m.host;
801 let rt = host.r.transpose();
802
803 // Host expressed in F (small coords) + reconciliation against the real mesh.
804 // ORIGIN-AWARE: the mesh may already be in a per-element local frame (wasm
805 // defaults `local_frame_enabled()` ON), where positions are relative to
806 // `mesh.origin` (world = origin + position). Frame the cut around
807 // `center - origin` so host_f = Rᵀ·(world_vertex - center) either way.
808 let o = mesh.origin;
809 let eff_center = Point3::new(
810 host.center.x - o[0],
811 host.center.y - o[1],
812 host.center.z - o[2],
813 );
814 let host_f = rotate_mesh_into_frame(mesh, &rt, &eff_center);
815 if host_f.positions.is_empty() {
816 return None;
817 }
818 let mut hmn = [f64::INFINITY; 3];
819 let mut hmx = [f64::NEG_INFINITY; 3];
820 for c in host_f.positions.chunks_exact(3) {
821 for k in 0..3 {
822 hmn[k] = hmn[k].min(c[k] as f64);
823 hmx[k] = hmx[k].max(c[k] as f64);
824 }
825 }
826 for k in 0..3 {
827 let ext = hmx[k] - hmn[k];
828 let lo = ext.min(2.0 * host.half[k]);
829 let hi = ext.max(2.0 * host.half[k]).max(1.0e-9);
830 if lo / hi < 0.99 {
831 return None;
832 }
833 }
834
835 // Opening boxes in F with the in-bounds + through-cut handling.
836 let mut boxes: Vec<([f64; 3], [f64; 3])> = Vec::with_capacity(param.openings.len());
837 for op in ¶m.openings {
838 let map = signed_permutation_map(&(rt * op.r), 1.0e-3)?;
839 let cf = rotate_point(
840 &rt,
841 op.center.x - host.center.x,
842 op.center.y - host.center.y,
843 op.center.z - host.center.z,
844 );
845 // The opening must penetrate along the wall's THIN (thickness) axis. If its
846 // extrude axis maps to a length/height axis, extending it across the host
847 // would wipe out a full slab — defer that case to the exact kernel.
848 let pen = (0..3).find(|&i| map[i].0 == 2)?;
849 if host.half[pen] > host.half[thin_axis(&host.half)] * 1.05 {
850 return None;
851 }
852 let mut half_f = [0.0f64; 3];
853 for i in 0..3 {
854 half_f[i] = op.half[map[i].0];
855 }
856 // In-bounds: the opening must lie within the wall on the in-face axes; an
857 // overrun is a partial intersection where box-clamp ≠ exact mesh-intersect.
858 for i in 0..3 {
859 if i == pen {
860 continue;
861 }
862 let tol = host.half[i] * 0.01 + 1.0e-4;
863 if cf[i] - half_f[i] < -host.half[i] - tol
864 || cf[i] + half_f[i] > host.half[i] + tol
865 {
866 return None;
867 }
868 }
869 // Cut the box AS AUTHORED (it is reconciled to equal the opening void), with a
870 // tiny margin on the penetration axis to avoid a flush-face coincidence. The
871 // earlier full-thickness override over-cut multi-prism openings (each authored
872 // box is a thin slice; extending each to the full thickness removes too much).
873 let eps = 1.0e-4;
874 let mut bmin = [cf[0] - half_f[0], cf[1] - half_f[1], cf[2] - half_f[2]];
875 let mut bmax = [cf[0] + half_f[0], cf[1] + half_f[1], cf[2] + half_f[2]];
876 bmin[pen] -= eps;
877 bmax[pen] += eps;
878 boxes.push((bmin, bmax));
879 }
880
881 // No-overlap: overlapping cutter boxes can diverge from the union-subtract.
882 for a in 0..boxes.len() {
883 for b in (a + 1)..boxes.len() {
884 let (amn, amx) = boxes[a];
885 let (bmn, bmx) = boxes[b];
886 if (0..3).all(|i| amn[i] < bmx[i] - 1.0e-4 && bmn[i] < amx[i] - 1.0e-4) {
887 return None;
888 }
889 }
890 }
891
892 let mut stats = crate::rect_fast::RectFastStats::default();
893 let cut_f = crate::rect_fast::subtract_rect_openings(&host_f, &boxes, &mut stats)?;
894 self.record_rect_fast(&stats);
895 let merged = crate::csg::ClippingProcessor::consolidate_coplanar(cut_f);
896 // Emit as a local-frame mesh (small positions + origin), then run the SAME
897 // hygiene the production output applies — in the small frame, where it is
898 // precise — so the self-check sees exactly what downstream will keep.
899 let mut out = rotate_mesh_from_frame(&merged, &host.r, &host.center);
900 out.clean_degenerate();
901
902 // Self-check: never emit a non-watertight cut; defer to the exact kernel.
903 if !param_cut_watertight(&out) {
904 return None;
905 }
906 crate::rect_fast::param_record_fire();
907 Some(out)
908 }
909
910 pub(super) fn apply_void_context(
911 &self,
912 mut mesh: Mesh,
913 ctx: &VoidContext,
914 element_id: u32,
915 ) -> Mesh {
916 // PARAMETRIC fast path (flag-gated). ORIGIN-AWARE: it handles both the world
917 // mesh (origin 0, native default) AND a per-element local-frame mesh (origin != 0,
918 // the wasm default — the precision-critical case this path is FOR), since it
919 // builds its own frame from the parametrics. Any miss falls through to the exact
920 // kernel below unchanged.
921 if crate::rect_fast::param_enabled() {
922 if let Some(fast) = self.try_param_rect_cut(&mesh, ctx) {
923 return fast;
924 }
925 }
926
927 // 2D OPENING-SUBTRACTION fast path (flag-gated). Generalises the rect
928 // parametric path above to arbitrary extruded-host profiles: subtract the
929 // openings' footprints from the host's 2D profile and re-extrude. Tried
930 // after `try_param_rect_cut` so proven rect+rect outputs are unchanged;
931 // ORIGIN-AWARE (it builds its own world frame from the parametrics and
932 // reconciles against the real host mesh, whatever `mesh.origin`). Any miss
933 // falls through to the exact kernel below unchanged.
934 if let Some(cut) = ctx.bool2d.as_ref() {
935 if let Some(holed) = self.try_bool2d_cut(&mesh, cut) {
936 // Eligible openings are now subtracted. Any residual (ineligible)
937 // openings — perpendicular sleeves, partial-depth recesses — are
938 // cut by the exact kernel on the re-extruded host (origin 0, world
939 // frame; residual cutters are world-framed), so a single
940 // ineligible opening no longer forfeits its host's cheap ones.
941 return match self.bool2d_residual(cut) {
942 None => holed,
943 Some(residual) => self.apply_void_context(holed, residual, element_id),
944 };
945 }
946 }
947
948 // ANALYTIC PRISM fast path (flag-gated): each opening whose cutter is
949 // a genuine stepped-extrusion prism (plain boxes AND rebated masonry
950 // windows) is subtracted from the host MESH analytically — host-shape-
951 // agnostic, so it fires on the faceted-BREP / clipped / multi-item
952 // hosts the parametric and 2D paths above cannot serve. Tried after
953 // them so their proven outputs stay byte-identical; ORIGIN-AWARE (the
954 // cut runs in the host's own local frame, cutters relativized in f64,
955 // `origin` re-stamped). Ineligible openings come back as a residual
956 // context and are cut by the exact kernel on the analytic result — the
957 // same composition contract as the 2D path (which also routes ITS
958 // residual through this path on recursion, so a 2D host's perpendicular
959 // sleeves take the prism cut too). Any miss falls through to the exact
960 // kernel below with the FULL opening set unchanged.
961 if prism_cut::enabled() {
962 // World bounds + triangle count captured BEFORE the cut so the
963 // per-host diagnostic matches what the exact/rect paths record.
964 let prism_bounds = world_host_bounds(&mesh);
965 let prism_tris_before = mesh.triangle_count();
966 if let Some((cut, residual)) = self.try_prism_cut(&mesh, ctx) {
967 return match residual {
968 None => {
969 // Same per-host cut-effect snapshot the exact path
970 // records, so prism-cut hosts aren't missing from the
971 // diagnostics.
972 self.record_host_cut_effect(
973 element_id,
974 prism_tris_before,
975 cut.triangle_count(),
976 ctx.merged_openings.len(),
977 prism_bounds,
978 );
979 cut
980 }
981 // With a residual, the recursive exact pass below records
982 // the (final) cut-effect snapshot itself.
983 Some(res) => self.apply_void_context(cut, &res, element_id),
984 };
985 }
986 }
987
988 // WORLD-space host AABB for the per-host diagnostic (`bbox`), captured
989 // HERE — before the local-frame branch below zeroes `mesh.origin` and
990 // before `try_cut_wall_local_frame` rotates the mesh into its own frame.
991 // On the wasm/local-frame default the host is stored relative to a
992 // nonzero `mesh.origin` (world = origin + position), so bounds taken
993 // after the clear would be local/near-zero and misdirect
994 // `diagnose-geometry --product/--type` (the #1474 local-frame-consumer
995 // bug class: every world-space reader must fold `MeshData.origin`). The
996 // origin is folded in f64 so georeferenced hosts report true world
997 // coords, then cast to the f32 diagnostic surface.
998 let host_world_bounds = world_host_bounds(&mesh);
999
1000 let origin = mesh.origin;
1001 if origin == [0.0, 0.0, 0.0] && ctx.all_cutters_world_framed() {
1002 // Legacy/world frame on host AND cutters: no relativization needed.
1003 return self.apply_void_context_inner(mesh, ctx, element_id, host_world_bounds);
1004 }
1005 // Work entirely in the host's local frame (origin 0 on every operand).
1006 // `relativized_by` folds each cutter's OWN origin and subtracts the host
1007 // origin in f64, so a per-element local-frame opening lands at the host
1008 // precisely. This also covers a host that snapped to origin 0 while its
1009 // openings did not (origin == 0 but cutters are local-framed).
1010 mesh.origin = [0.0, 0.0, 0.0];
1011 let local_ctx = ctx.relativized_by(origin);
1012 let mut result =
1013 self.apply_void_context_inner(mesh, &local_ctx, element_id, host_world_bounds);
1014 result.origin = origin;
1015 result
1016 }
1017
1018 /// Try the analytic rectangular-opening fast path. Returns the watertight
1019 /// cut mesh iff EVERY merged opening is an axis-aligned `Rectangular`
1020 /// through-cut and the host is a clean axis-aligned box; otherwise `None`
1021 /// (→ the exact kernel handles it). A mixed opening set defers the whole
1022 /// host rather than composing analytic + exact cuts.
1023 fn try_rect_fast(&self, host: &Mesh, ctx: &VoidContext) -> Option<Mesh> {
1024 let (wmn, wmx) = host.bounds();
1025 let wall_min = Point3::new(wmn.x as f64, wmn.y as f64, wmn.z as f64);
1026 let wall_max = Point3::new(wmx.x as f64, wmx.y as f64, wmx.z as f64);
1027 let mut boxes: Vec<([f64; 3], [f64; 3])> =
1028 Vec::with_capacity(ctx.merged_openings.len());
1029 for op in &ctx.merged_openings {
1030 match op {
1031 OpeningType::Rectangular(omn, omx, dir) => {
1032 let (fmn, fmx) = match dir {
1033 Some(d) => self.extend_opening_along_direction(
1034 *omn, *omx, wall_min, wall_max, *d,
1035 ),
1036 None => (*omn, *omx),
1037 };
1038 boxes.push(([fmn.x, fmn.y, fmn.z], [fmx.x, fmx.y, fmx.z]));
1039 }
1040 _ => return None,
1041 }
1042 }
1043 let mut stats = crate::rect_fast::RectFastStats::default();
1044 let out = crate::rect_fast::subtract_rect_openings(host, &boxes, &mut stats);
1045 self.record_rect_fast(&stats);
1046 // The cellular cut conformingly splits EVERY face by ALL grid lines (so
1047 // adjacent cells share edges → watertight), which over-fragments faces an
1048 // opening doesn't reach. Run the result through the SAME coplanar merge
1049 // the exact path uses (i_overlay union per plane) to collapse those back
1050 // to minimal triangles — keeps it watertight and un-bloated.
1051 out.map(crate::csg::ClippingProcessor::consolidate_coplanar)
1052 }
1053
1054 /// Cut a plan-rotated wall's openings in the wall's own axis-aligned,
1055 /// origin-centred frame (issue #1167). Returns `None` (caller uses the
1056 /// world path) unless an opening supplies a non-axis-aligned, ~horizontal
1057 /// depth axis (a vertical wall rotated in plan) and every opening carries a
1058 /// cutter mesh.
1059 ///
1060 /// In the wall frame the host and its openings are axis-aligned and near the
1061 /// origin, so the exact subtract runs in the clean, f32-precise regime a
1062 /// straight wall enjoys — clean-box openings even reclassify to the
1063 /// watertight `rect_fast` path. Curved / brep openings keep their mesh and
1064 /// are subtracted there too. The result is rotated back; the orthonormal,
1065 /// origin-centred round-trip is identity for untouched geometry. Recursing
1066 /// into [`Self::apply_void_context_inner`] is safe: in the frame every
1067 /// opening's depth is +Z (axis-aligned), so this guard returns `None` on the
1068 /// inner call.
1069 fn try_cut_wall_local_frame(
1070 &self,
1071 mesh: &Mesh,
1072 ctx: &VoidContext,
1073 element_id: u32,
1074 host_world_bounds: ((f32, f32, f32), (f32, f32, f32)),
1075 ) -> Option<Mesh> {
1076 if ctx.merged_openings.is_empty() {
1077 return None;
1078 }
1079 let depth_of = |op: &OpeningType| -> Option<Vector3<f64>> {
1080 match op {
1081 OpeningType::DiagonalRectangular(_, f) => Some(f.depth),
1082 OpeningType::NonRectangular(_, _, _, d) => *d,
1083 OpeningType::Rectangular(_, _, d) => *d,
1084 }
1085 };
1086 // Define the wall frame from the first opening whose depth is a
1087 // genuinely rotated, ~horizontal axis. Axis-aligned walls find none and
1088 // keep their (unchanged) world path.
1089 let axes = ctx
1090 .merged_openings
1091 .iter()
1092 .filter_map(depth_of)
1093 .find(|d| !is_axis_aligned_direction(d) && d.z.abs() <= 0.2)
1094 .and_then(wall_frame_from_depth)?;
1095
1096 // AABB-only `Rectangular` openings can't be rotated into the frame; a
1097 // plan-rotated wall never has them (they'd be diagonal), so bail.
1098 if ctx
1099 .merged_openings
1100 .iter()
1101 .any(|op| matches!(op, OpeningType::Rectangular(..)))
1102 {
1103 return None;
1104 }
1105
1106 let (mn, mx) = mesh.bounds();
1107 let center = Vector3::new(
1108 ((mn.x + mx.x) * 0.5) as f64,
1109 ((mn.y + mx.y) * 0.5) as f64,
1110 ((mn.z + mx.z) * 0.5) as f64,
1111 );
1112 let host_local = mesh_to_frame(mesh, &axes, center);
1113
1114 let z = Vector3::new(0.0, 0.0, 1.0);
1115 let mut local_openings: Vec<OpeningType> = Vec::with_capacity(ctx.merged_openings.len());
1116 for op in &ctx.merged_openings {
1117 let cutter = match op {
1118 OpeningType::DiagonalRectangular(m, _) => m,
1119 OpeningType::NonRectangular(m, _, _, _) => m,
1120 OpeningType::Rectangular(..) => return None,
1121 };
1122 let (lmn, lmx) = project_aabb_in_frame(cutter, &axes, center)?;
1123 let in_frame =
1124 |v: Vector3<f64>| Vector3::new(v.dot(&axes[0]), v.dot(&axes[1]), v.dot(&axes[2]));
1125 // The cutter's own penetration (depth) axis expressed in the wall
1126 // frame. Drives both the alignment test and — for the exact fallback
1127 // — the cap-extension direction, which MUST stay faithful to THIS
1128 // cutter: hardcoding the wall normal would push a misaligned
1129 // opening's flush/short cap along the wrong axis and carve the wrong
1130 // prism (#1270 review).
1131 let frame_depth = match op {
1132 OpeningType::DiagonalRectangular(_, f) => Some(in_frame(f.depth)),
1133 OpeningType::NonRectangular(_, _, _, d) => d.map(in_frame),
1134 OpeningType::Rectangular(..) => None,
1135 };
1136 // Whether THIS opening's own oriented box is axis-aligned in the
1137 // wall frame. The frame is seeded from the FIRST rotated opening
1138 // (#1167); a second opening tilted differently *in plane* is NOT
1139 // axis-aligned here, so projecting its frame AABB would over-cut it
1140 // (#1259 review). Only a clean box whose full frame aligns with the
1141 // wall frame may take the fast rectangular cut; everything else —
1142 // brep/curved voids and frame-misaligned boxes alike — keeps its
1143 // exact (un-rotated, centred) mesh for the subtract.
1144 let frame_aligned = match op {
1145 OpeningType::DiagonalRectangular(_, f) => {
1146 is_axis_aligned_direction(&in_frame(f.depth))
1147 && is_axis_aligned_direction(&in_frame(f.cross_a))
1148 && is_axis_aligned_direction(&in_frame(f.cross_b))
1149 }
1150 _ => false,
1151 };
1152 if frame_aligned {
1153 local_openings.push(OpeningType::Rectangular(lmn, lmx, Some(z)));
1154 } else {
1155 let mesh_local = mesh_to_frame(cutter, &axes, center);
1156 // Keep this cutter's true depth in the frame; fall back to the
1157 // wall normal (+Z) only when the opening carried no direction.
1158 let dir = frame_depth.unwrap_or(z);
1159 local_openings.push(OpeningType::NonRectangular(mesh_local, lmn, lmx, Some(dir)));
1160 }
1161 }
1162 let local_ctx = VoidContext {
1163 merged_openings: Self::merge_rectangular_openings(&local_openings),
1164 openings: local_openings,
1165 // The local-frame recursion never re-captures the parametric cut
1166 // (issue #1209): it operates on already-classified openings, so the
1167 // analytic `param` / 2D paths are irrelevant here — defer to the exact
1168 // path.
1169 param: None,
1170 bool2d: None,
1171 };
1172
1173 // Forward the WORLD host bounds captured before this rotation so the
1174 // diagnostic reports world coords, not wall-frame (rotated/centred) ones.
1175 let result_local =
1176 self.apply_void_context_inner(host_local, &local_ctx, element_id, host_world_bounds);
1177 Some(mesh_from_frame(&result_local, &axes, center))
1178 }
1179
1180 // `host_mutated` is set just before an early `break`, so the final write is
1181 // intentionally never read back; keep the flag for readability of the branch.
1182 #[allow(unused_assignments)]
1183 fn apply_void_context_inner(
1184 &self,
1185 mesh: Mesh,
1186 ctx: &VoidContext,
1187 element_id: u32,
1188 host_bounds_capture: ((f32, f32, f32), (f32, f32, f32)),
1189 ) -> Mesh {
1190 // Capture the input triangle count so the per-host diagnostic can flag
1191 // the "cuts attempted but produced no change" case — the silent-no-op
1192 // signature when an opening box doesn't intersect the host mesh. The
1193 // WORLD-space host AABB (`host_bounds_capture`) is passed in from
1194 // `apply_void_context` (captured before the origin clear / frame
1195 // rotation), not recomputed here from the possibly local-framed `mesh`.
1196 let tris_before = mesh.triangle_count();
1197 if ctx.is_noop() {
1198 return mesh;
1199 }
1200 let original_host = mesh.clone(); // stray-shard sweep parity reference
1201
1202 // LOCAL-FRAME CUT (issue #1167): a vertical wall rotated in plan is cut
1203 // in its own axis-aligned, origin-centred frame — where the exact
1204 // subtract is clean and f32-precise — then the result is rotated back.
1205 // The world-space tilted cut at large coordinates over-cuts and
1206 // fragments badly. Scoped to plan-rotated walls; everything else falls
1207 // through to the world path unchanged.
1208 if let Some(cut) = self.try_cut_wall_local_frame(&mesh, ctx, element_id, host_bounds_capture)
1209 {
1210 return cut;
1211 }
1212
1213 let clipper = ClippingProcessor::new();
1214 // ROOT-CAUSE FIX (issue #1007, host #1112): correct f32 facet jitter on
1215 // the host triangle soup BEFORE the exact-kernel cut. A faceted-BREP
1216 // roof slope authored as ONE flat plane comes back from the f32 import
1217 // with adjacent facets ~0.09° non-coplanar. That jitter (a) splits the
1218 // slope into many one-triangle plane buckets in `consolidate_coplanar`
1219 // — a single-triangle bucket bypasses the CDT and is emitted as a 25:1
1220 // far-corner sliver fan — and (b) blocks clean coalescing of the cut
1221 // hole. Welding near-coplanar adjacent facets (≤0.15°, well below any
1222 // real roof pitch) to a single least-squares plane makes the slope
1223 // EXACTLY coplanar, so the cut emits one CDT-refined region (rim sliver
1224 // gone) with a clean opening hole. Deterministic + watertight + grid-
1225 // snapped; a no-op for already-planar extrusion hosts.
1226 let mut result = crate::facet_weld::weld_near_coplanar_facets(&mesh);
1227
1228 // ANALYTIC FAST PATH: an axis-aligned box host whose openings are ALL
1229 // axis-aligned rectangular through-cuts is subtracted analytically
1230 // (`rect_fast`), skipping the exact mesh-arrangement kernel — the
1231 // ~80 s memory-bandwidth-bound void-cut window's dominant cost. Pure
1232 // optimization: any precondition miss (non-box host, mixed/non-rect
1233 // openings, near-edge feature) returns `None` and the host falls through
1234 // to the exact path below unchanged. Fired BEFORE the dense-host
1235 // subdivision (that workaround is only for the exact kernel).
1236 if crate::rect_fast::enabled() {
1237 if let Some(fast) = self.try_rect_fast(&result, ctx) {
1238 // Same per-host cut-effect snapshot the exact path records below,
1239 // so fast-path hosts aren't missing from the diagnostics.
1240 self.record_host_cut_effect(
1241 element_id,
1242 tris_before,
1243 fast.triangle_count(),
1244 ctx.merged_openings.len(),
1245 host_bounds_capture,
1246 );
1247 return fast;
1248 }
1249 }
1250
1251 // OPENING-DENSE HOST REFINEMENT: when many openings target the same host
1252 // (a window wall is usually 2 big face-triangles per side), every cut's
1253 // intersection segments pile onto those few triangles — the exact
1254 // arrangement then re-triangulates a single triangle carrying dozens of
1255 // constraints (O(k²)), and the batched N-ary subtract leaves unrecovered
1256 // constraints and degrades to the O(N²) sequential path. Pre-subdividing
1257 // the host spreads the segments across many small triangles (small k each)
1258 // so the batched cut recovers. `consolidate_coplanar` re-triangulates each
1259 // coplanar group afterwards, so the temporary interior vertices don't
1260 // bloat the final mesh. Levels are scaled to opening count and capped.
1261 let n_openings = ctx.merged_openings.len();
1262 if n_openings >= 8 {
1263 // Just enough subdivision that each host triangle carries only a few
1264 // intersection segments, so the batched N-ary subtract RECOVERS rather
1265 // than degrading to the O(N²) sequential path — that recovery is the
1266 // win (≈10× on the densest walls), not the per-triangle segment count
1267 // itself. Over-subdividing is counter-productive: the extra triangles
1268 // cost more in the arrangement than the spreading saves (level 3 was
1269 // ~3× slower than level 1 on a 14-opening wall). Aim for ≳ a handful of
1270 // host triangles per opening, capped at 2 levels.
1271 let host_tris = result.triangle_count().max(1);
1272 let target = 4 * n_openings;
1273 let mut levels = 0usize;
1274 while host_tris * (1usize << (2 * (levels + 1))) < target && levels < 2 {
1275 levels += 1;
1276 }
1277 // A COARSE host (a box wall is ~12 triangles) still needs one split
1278 // even when the next level would overshoot the target; an ALREADY-dense
1279 // host (e.g. a faceted-BREP wall whose triangle count already meets the
1280 // target) is left untouched — extra geometry there only slows the cut.
1281 if levels == 0 && host_tris < target {
1282 levels = 1;
1283 }
1284 if levels > 0 {
1285 result = result.subdivided(levels);
1286 }
1287 }
1288
1289 let (wall_min_f32, wall_max_f32) = result.bounds();
1290 let wall_min = Point3::new(
1291 wall_min_f32.x as f64,
1292 wall_min_f32.y as f64,
1293 wall_min_f32.z as f64,
1294 );
1295 let wall_max = Point3::new(
1296 wall_max_f32.x as f64,
1297 wall_max_f32.y as f64,
1298 wall_max_f32.z as f64,
1299 );
1300
1301 let wall_valid = !result.is_empty()
1302 && result.positions.iter().all(|&v| v.is_finite())
1303 && result.triangle_count() >= 4;
1304
1305 if !wall_valid {
1306 return result;
1307 }
1308
1309 // NOTE: there is deliberately NO per-element CSG operation budget here.
1310 // The BSP-era `MAX_CSG_OPERATIONS = 10` cap silently skipped the 11th+
1311 // opening (it `continue`d past BOTH the exact subtract AND the #635 AABB
1312 // fallback), which is exactly the regression `csg_void_test::
1313 // many_tessellated_box_openings_are_all_cut` pins (history: #413/#439).
1314 // On the unified exact path every opening is a cheap box-vs-host cut, so
1315 // a budget-skipped opening is a correctness bug, not a perf guard.
1316
1317 // UNIFIED EXACT PATH (PART B): every opening — axis-aligned RECTANGULAR
1318 // included — is now subtracted by the exact mesh kernel, NOT the legacy
1319 // Sutherland-Hodgman AABB clip. A `Rectangular` opening is materialised as
1320 // a PENETRATING box mesh (its bounds extended through the wall along the
1321 // extrusion axis by `extend_opening_along_direction`, so both caps poke past
1322 // the host ⇒ a transversal cut with no flush-cap sliver — the same robust
1323 // condition PART A guarantees for tilted openings). The exact subtract emits
1324 // the void's interior reveal faces itself, so the explicit reveal/recess
1325 // quad generators are no longer needed on this path.
1326 //
1327 // `synth_rect` owns the synthesised box meshes so they outlive the loop's
1328 // borrowed `&OpeningType`s below.
1329 let mut synth_rect: Vec<OpeningType> = Vec::new();
1330 let mut non_rect_openings: Vec<&OpeningType> = Vec::new();
1331 for opening in &ctx.merged_openings {
1332 match opening {
1333 OpeningType::Rectangular(open_min, open_max, extrusion_dir) => {
1334 // Penetration axis: the authored extrusion dir when present,
1335 // else inferred from how the box pierces the host (issue
1336 // #1337). Carrying a concrete dir downstream keeps the
1337 // through-host cap-flush extension off the opening's thinnest
1338 // (in-plane) axis for deep cutters.
1339 let dir = extrusion_dir.unwrap_or_else(|| {
1340 infer_box_penetration_dir(open_min, open_max, &wall_min, &wall_max)
1341 });
1342 // Only openings with an AUTHORED extrusion dir were extended
1343 // here before; keep the synthesized box identical for the
1344 // dirless case (the inferred dir only steers the later
1345 // through-host extension, not the box bounds).
1346 let (final_min, final_max) = if extrusion_dir.is_some() {
1347 self.extend_opening_along_direction(
1348 *open_min, *open_max, wall_min, wall_max, dir,
1349 )
1350 } else {
1351 (*open_min, *open_max)
1352 };
1353 let box_mesh = Self::make_box_mesh(final_min, final_max);
1354 synth_rect.push(OpeningType::NonRectangular(
1355 box_mesh,
1356 final_min,
1357 final_max,
1358 Some(dir),
1359 ));
1360 }
1361 // A MALFORMED (self-intersecting) tessellated cutter is NOT cut
1362 // here: its messy mesh under-cuts the opening, and double-cutting
1363 // it (here AND in the clean-box `recut_malformed_openings` pass)
1364 // leaves overlapping sliver "shards" in the reveals. Skip it; the
1365 // recut performs the single, clean box cut for these openings.
1366 OpeningType::NonRectangular(m, ..) if opening_obb_if_malformed(m).is_some() => {}
1367 other => non_rect_openings.push(other),
1368 }
1369 }
1370 let all_openings: Vec<&OpeningType> =
1371 synth_rect.iter().chain(non_rect_openings.iter().copied()).collect();
1372
1373 // DISJOINT-CUTTER BATCHING: group cutters whose pad-inflated AABBs are
1374 // pairwise disjoint and subtract each group in ONE conforming arrangement
1375 // (`ClippingProcessor::subtract_mesh_many`). Sequential per-opening
1376 // subtraction re-arranges the (growing) host once per cutter, and each
1377 // intermediate f64→f32→snap round-trip re-jitters carve vertices off
1378 // shared planes so cut N+1 re-cracks what cut N reconciled (many-void
1379 // walls' compounding open edges, the 16-void slab's ~3.5 s cost). Batching
1380 // admits only openings passing the SAME guards as the sequential loop plus
1381 // per-component watertightness (#2176). Singletons and any group whose
1382 // batched cut fails its guards — or the kernel conformity gate
1383 // (`subtract_mesh_many` rejects a group whose N-ary arrangement left an
1384 // unrecovered constraint) — fall through to the per-opening sequential
1385 // loop below with its full #635 fallback / engulf / redundant-void machinery.
1386 let mut batch_consumed: Vec<bool> = vec![false; all_openings.len()];
1387 // Disjoint groups of opening indices (len ≥ 2 only); each is cut
1388 // INLINE at its first member's position in the sequential loop below,
1389 // so the relative order of batched vs sequential cutters matches the
1390 // pure sequential pass — only the order WITHIN a group (mutually
1391 // disjoint cutters, the provably order-free case) is collapsed.
1392 let mut batch_groups: Vec<Vec<(usize, Mesh)>> = Vec::new();
1393 let mut batch_group_of: FxHashMap<usize, usize> = FxHashMap::default();
1394 // Set on every successful cut; while false, a group's admission-time
1395 // extended cutters are still valid and reused verbatim.
1396 let mut host_mutated = false;
1397
1398 // COAXIAL FOOTPRINT-UNION for OVERLAPPING clusters (issue #129, flag
1399 // `IFC_LITE_VOID_UNION`). Overlapping cutters can't join the disjoint batch
1400 // below and otherwise fall to the O(N) sequential exact path; this fuses
1401 // each overlapping coaxial cluster into disjoint re-extruded prisms and cuts
1402 // them in one `subtract_mesh_many`. See `coaxial_union`. Marks consumed
1403 // openings so the batch + sequential loops skip them; a cluster that fails
1404 // any guard is left unconsumed for the exact path (never worse than exact).
1405 self.coaxial_union_prepass(
1406 &mut result,
1407 &all_openings,
1408 &mut batch_consumed,
1409 &mut host_mutated,
1410 &clipper,
1411 );
1412
1413 if all_openings.len() >= 2 {
1414 // Inflation pad: ≥ 2×(promote band 8·2⁻¹⁶ ≈ 122 µm + snap radius);
1415 // 1 mm is conservative and far below any real opening separation.
1416 // Touching/overlapping cutters land in DIFFERENT groups, cut in
1417 // sequence — overlap degrades gracefully to sequential behavior.
1418 const BATCH_PAD: f64 = 1.0e-3;
1419 struct Cand {
1420 idx: usize,
1421 /// The admission-time extended cutter (host PRE-cut). Reused at
1422 /// cut time while the host is still unmutated — the common case
1423 /// (groups are cut at their FIRST member, usually before any
1424 /// sequential cut) — so extension isn't paid twice.
1425 mesh: Mesh,
1426 lo: [f64; 3],
1427 hi: [f64; 3],
1428 }
1429 let mut cands: Vec<Cand> = Vec::new();
1430 for (idx, opening) in all_openings.iter().enumerate() {
1431 // Already cut by the coaxial/overlap-union prepass above.
1432 if batch_consumed[idx] {
1433 continue;
1434 }
1435 let norm: Option<(&Mesh, Option<Vector3<f64>>)> = match **opening {
1436 OpeningType::Rectangular(..) => None,
1437 OpeningType::DiagonalRectangular(ref m, ref f) => Some((m, Some(f.depth))),
1438 OpeningType::NonRectangular(ref m, _, _, ref d) => Some((m, *d)),
1439 };
1440 let Some((opening_mesh, extrusion_dir)) = norm else { continue };
1441 // Same admission guards as the sequential loop.
1442 let opening_valid = !opening_mesh.is_empty()
1443 && opening_mesh.positions.iter().all(|&v| v.is_finite())
1444 && opening_mesh.positions.len() >= 9;
1445 if !opening_valid {
1446 continue;
1447 }
1448 let (result_min, result_max) = result.bounds();
1449 let (omn, omx) = opening_mesh.bounds();
1450 let no_overlap = omx.x < result_min.x
1451 || omn.x > result_max.x
1452 || omx.y < result_min.y
1453 || omn.y > result_max.y
1454 || omx.z < result_min.z
1455 || omn.z > result_max.z;
1456 if no_overlap {
1457 continue;
1458 }
1459 let open_vol = (omx.x - omn.x) as f64
1460 * (omx.y - omn.y) as f64
1461 * (omx.z - omn.z) as f64;
1462 if open_vol < Self::min_opening_volume(self.tessellation_quality) {
1463 continue;
1464 }
1465 let depth_dir = extrusion_dir
1466 .filter(|d| d.norm() > NORMALIZE_EPSILON)
1467 .unwrap_or_else(|| opening_mesh_thinnest_axis_dir(opening_mesh));
1468 // Weld (1 µm) to bit-identical so a geometrically-watertight cutter
1469 // whose shared-edge f32 coords differ in bits after the placement
1470 // transform still passes the bit-exact closure gate below and can
1471 // join a batch instead of re-jittering through sequential cuts (#098).
1472 let ext = Self::extend_opening_mesh_through_host(opening_mesh, &result, depth_dir)
1473 .welded_by_position(1.0e-6);
1474 // #2176: only per-component-watertight solids may join a group.
1475 if !mesh_is_closed_exact(&ext) {
1476 continue;
1477 }
1478 let (lo, hi) = ext.bounds();
1479 // Engulf-class exclusion: a cutter whose extended AABB covers
1480 // the whole host on EVERY axis (3% slack, the sequential
1481 // engulf test) stays on the sequential path, where the
1482 // near-engulf and redundant-void guards live — batched it can
1483 // shave the host's outer shell (the #559171-family residual).
1484 let engulfs = {
1485 let tol = ENGULF_TOLERANCE;
1486 let covers = |omin: f64, omax: f64, wmin: f64, wmax: f64| {
1487 let slack = (wmax - wmin).abs().max(1.0e-9) * tol;
1488 omin <= wmin + slack && omax >= wmax - slack
1489 };
1490 covers(lo.x as f64, hi.x as f64, wall_min.x, wall_max.x)
1491 && covers(lo.y as f64, hi.y as f64, wall_min.y, wall_max.y)
1492 && covers(lo.z as f64, hi.z as f64, wall_min.z, wall_max.z)
1493 };
1494 if engulfs {
1495 continue;
1496 }
1497 cands.push(Cand {
1498 idx,
1499 mesh: ext,
1500 lo: [
1501 lo.x as f64 - BATCH_PAD,
1502 lo.y as f64 - BATCH_PAD,
1503 lo.z as f64 - BATCH_PAD,
1504 ],
1505 hi: [
1506 hi.x as f64 + BATCH_PAD,
1507 hi.y as f64 + BATCH_PAD,
1508 hi.z as f64 + BATCH_PAD,
1509 ],
1510 });
1511 }
1512 // Greedy disjoint grouping, deterministic in opening order: a
1513 // candidate joins the first group whose EVERY member's inflated
1514 // AABB is disjoint from its own.
1515 let mut groups: Vec<Vec<usize>> = Vec::new();
1516 'cand: for ci in 0..cands.len() {
1517 for g in groups.iter_mut() {
1518 let disjoint_from_all = g.iter().all(|&cj| {
1519 let (a, b) = (&cands[ci], &cands[cj]);
1520 a.hi[0] < b.lo[0]
1521 || a.lo[0] > b.hi[0]
1522 || a.hi[1] < b.lo[1]
1523 || a.lo[1] > b.hi[1]
1524 || a.hi[2] < b.lo[2]
1525 || a.lo[2] > b.hi[2]
1526 });
1527 if disjoint_from_all {
1528 g.push(ci);
1529 continue 'cand;
1530 }
1531 }
1532 groups.push(vec![ci]);
1533 }
1534 for g in &groups {
1535 if g.len() < 2 {
1536 continue; // singleton: sequential loop handles it (full guards)
1537 }
1538 let gid = batch_groups.len();
1539 for &ci in g {
1540 batch_group_of.insert(cands[ci].idx, gid);
1541 }
1542 batch_groups
1543 .push(g.iter().map(|&ci| (cands[ci].idx, cands[ci].mesh.clone())).collect());
1544 }
1545 }
1546
1547 for (opening_idx, opening) in all_openings.iter().enumerate() {
1548 if batch_consumed[opening_idx] {
1549 continue; // already cut as part of a batched disjoint group
1550 }
1551 // Batched group cut, attempted ONCE, inline at the group's first
1552 // member — so the relative order of batched vs sequential cutters
1553 // matches the pure sequential pass. On any failure (admission,
1554 // guards, or the kernel's conformity gate) the members fall back
1555 // to the per-opening sequential path below.
1556 if let Some(&gid) = batch_group_of.get(&opening_idx) {
1557 let members = std::mem::take(&mut batch_groups[gid]);
1558 if members.len() >= 2 {
1559 // While the host is unmutated, the admission-time extended
1560 // cutters (built against this exact host) are reused as-is;
1561 // after any cut, members are re-extended against the
1562 // CURRENT host — matching the sequential loop's reference,
1563 // which extends each cutter against the (k−1)-cut host.
1564 let mut extended: Vec<(usize, Mesh)> = Vec::with_capacity(members.len());
1565 let mut admissible = true;
1566 if !host_mutated {
1567 extended = members;
1568 } else {
1569 for &(m_idx, _) in &members {
1570 let norm: Option<(&Mesh, Option<Vector3<f64>>)> =
1571 match *all_openings[m_idx] {
1572 OpeningType::Rectangular(..) => None,
1573 OpeningType::DiagonalRectangular(ref m, ref f) => {
1574 Some((m, Some(f.depth)))
1575 }
1576 OpeningType::NonRectangular(ref m, _, _, ref d) => {
1577 Some((m, *d))
1578 }
1579 };
1580 let Some((opening_mesh, extrusion_dir)) = norm else {
1581 admissible = false;
1582 break;
1583 };
1584 let depth_dir = extrusion_dir
1585 .filter(|d| d.norm() > NORMALIZE_EPSILON)
1586 .unwrap_or_else(|| opening_mesh_thinnest_axis_dir(opening_mesh));
1587 let ext = Self::extend_opening_mesh_through_host(
1588 opening_mesh,
1589 &result,
1590 depth_dir,
1591 );
1592 // the re-extended cutter must stay watertight (#2176)
1593 if !mesh_is_closed_exact(&ext) {
1594 admissible = false;
1595 break;
1596 }
1597 extended.push((m_idx, ext));
1598 }
1599 }
1600 if admissible {
1601 let cutters: Vec<&Mesh> = extended.iter().map(|(_, m)| m).collect();
1602 let tri_before = result.triangle_count();
1603 let vol_before = mesh_signed_volume(&result);
1604 if let Ok(csg_result) = clipper.subtract_mesh_many(&result, &cutters) {
1605 let min_tris = (tri_before / CSG_TRIANGLE_RETENTION_DIVISOR)
1606 .max(MIN_VALID_TRIANGLES);
1607 let changed = cut_changed_mesh(&csg_result, tri_before, vol_before);
1608 if !csg_result.is_empty()
1609 && csg_result.triangle_count() >= min_tris
1610 && changed
1611 {
1612 result = csg_result;
1613 host_mutated = true;
1614 for &(m_idx, _) in &extended {
1615 batch_consumed[m_idx] = true;
1616 }
1617 }
1618 }
1619 }
1620 }
1621 if batch_consumed[opening_idx] {
1622 continue; // this opening was cut with its group
1623 }
1624 }
1625 // Normalize both exact-subtract variants into the same (mesh, min,
1626 // max, dir) shape. `DiagonalRectangular` (a clean but tilted box —
1627 // e.g. a roof-slope window or a slanted roof opening, #1007 defect B)
1628 // is a REAL solid cutter, so it MUST be subtracted exactly, never
1629 // approximated by a frame-rotated AABB: that legacy path
1630 // (`apply_diagonal_openings`) tore the host (101 boundary edges) and
1631 // left the void uncut. Route it through the same exact-mesh subtract
1632 // as `NonRectangular`; the kernel cuts the tilted box cleanly
1633 // (winding-robust after the defect-A orient-outward fix). The
1634 // mesh-bounds AABB + frame-depth direction still seed the #635
1635 // fallback, which only fires if the exact subtract no-ops.
1636 let normalized: Option<(&Mesh, Point3<f64>, Point3<f64>, Option<Vector3<f64>>)> =
1637 match *opening {
1638 OpeningType::Rectangular(..) => None,
1639 OpeningType::DiagonalRectangular(ref opening_mesh, ref frame) => {
1640 let (mn, mx) = opening_mesh.bounds();
1641 Some((
1642 opening_mesh,
1643 Point3::new(mn.x as f64, mn.y as f64, mn.z as f64),
1644 Point3::new(mx.x as f64, mx.y as f64, mx.z as f64),
1645 Some(frame.depth),
1646 ))
1647 }
1648 OpeningType::NonRectangular(
1649 ref opening_mesh,
1650 ref open_min_pt,
1651 ref open_max_pt,
1652 ref extrusion_dir,
1653 ) => Some((opening_mesh, *open_min_pt, *open_max_pt, *extrusion_dir)),
1654 };
1655 if let Some((opening_mesh, open_min_pt, open_max_pt, extrusion_dir)) = normalized {
1656 let open_min_pt = &open_min_pt;
1657 let open_max_pt = &open_max_pt;
1658 {
1659 let opening_valid = !opening_mesh.is_empty()
1660 && opening_mesh.positions.iter().all(|&v| v.is_finite())
1661 && opening_mesh.positions.len() >= 9;
1662
1663 if !opening_valid {
1664 continue;
1665 }
1666
1667 let (result_min, result_max) = result.bounds();
1668 let (open_min_f32, open_max_f32) = opening_mesh.bounds();
1669 let no_overlap = open_max_f32.x < result_min.x
1670 || open_min_f32.x > result_max.x
1671 || open_max_f32.y < result_min.y
1672 || open_min_f32.y > result_max.y
1673 || open_max_f32.z < result_min.z
1674 || open_min_f32.z > result_max.z;
1675 if no_overlap {
1676 continue;
1677 }
1678
1679 let open_vol = (open_max_f32.x - open_min_f32.x)
1680 * (open_max_f32.y - open_min_f32.y)
1681 * (open_max_f32.z - open_min_f32.z);
1682 // The 0.1 L volume floor filtered legacy-BSP CSG artefacts but
1683 // also drops genuine small openings — bolt holes / sleeves in
1684 // thin plates. At the two highest quality levels keep those
1685 // holes (the exact kernel is stable on small cutters); only
1686 // reject numerically degenerate cutters there. (issue #976)
1687 let min_open_vol = Self::min_opening_volume(self.tessellation_quality) as f32;
1688 if open_vol < min_open_vol {
1689 continue;
1690 }
1691
1692 // ENGULFING-SOLID VOID: when this opening's real solid
1693 // CONTAINS the whole host, the exact subtract no-ops on the
1694 // coincident shared faces and returns the host unchanged in
1695 // volume (a spurious solid where the opening should be). A
1696 // cheap AABB-engulf pre-check (false for an ordinary opening,
1697 // which spans the host on at most its thickness axis) gates
1698 // the O(host_v · opening_t) containment scan, which confirms
1699 // TRUE solid containment — so a void whose AABB engulfs the
1700 // host while its real profile excludes it is not affected.
1701 // On a hit the host is fully consumed.
1702 let aabb_engulfs = {
1703 let tol = ENGULF_TOLERANCE_F32;
1704 let covers = |omin: f32, omax: f32, hmin: f32, hmax: f32| {
1705 let slack = (hmax - hmin).abs().max(1.0e-9) * tol;
1706 omin <= hmin + slack && omax >= hmax - slack
1707 };
1708 covers(open_min_f32.x, open_max_f32.x, result_min.x, result_max.x)
1709 && covers(open_min_f32.y, open_max_f32.y, result_min.y, result_max.y)
1710 && covers(open_min_f32.z, open_max_f32.z, result_min.z, result_max.z)
1711 };
1712 if aabb_engulfs && opening_engulfs_host_solid(&result, opening_mesh) {
1713 // Mark the host consumed so the element pipeline keeps
1714 // the empty result instead of falling back to the un-cut
1715 // host.
1716 self.record_void_consumed_host(element_id);
1717 result = Mesh::default();
1718 host_mutated = true;
1719 break;
1720 }
1721
1722 let tri_before = result.triangle_count();
1723 let failures_before = clipper.failure_count();
1724 let mut csg_succeeded = false;
1725 // Tracks whether CSG returned the host *unchanged* (the kernel
1726 // either found no real intersection, or errored on a grazing/
1727 // coplanar cutter and returned the un-cut host).
1728 let mut csg_unchanged = false;
1729 // PENETRATING CUTTER (PART A): push the opening's caps a hair
1730 // PAST the host along its depth axis so a flush cap becomes a
1731 // clean transversal crossing — the exact kernel then cuts the
1732 // tilted, faceted roof opening with no bridging sliver (#1007
1733 // host #1112). Falls back to the raw opening mesh when no depth
1734 // direction is known (the kernel handles a true through-cutter
1735 // anyway; the extension only matters for the flush-cap case).
1736 let depth_dir = extrusion_dir
1737 .filter(|d| d.norm() > NORMALIZE_EPSILON)
1738 .unwrap_or_else(|| opening_mesh_thinnest_axis_dir(opening_mesh));
1739 let extended_opening = Self::extend_opening_mesh_through_host(
1740 opening_mesh,
1741 &result,
1742 depth_dir,
1743 );
1744 let cutter = &extended_opening;
1745 let vol_before = mesh_signed_volume(&result);
1746 if let Ok(csg_result) = clipper.subtract_mesh(&result, cutter) {
1747 let min_tris = (tri_before / CSG_TRIANGLE_RETENTION_DIVISOR)
1748 .max(MIN_VALID_TRIANGLES);
1749 let changed = cut_changed_mesh(&csg_result, tri_before, vol_before);
1750 csg_unchanged = !changed;
1751 if !csg_result.is_empty()
1752 && csg_result.triangle_count() >= min_tris
1753 && changed
1754 {
1755 result = csg_result;
1756 host_mutated = true;
1757 csg_succeeded = true;
1758 }
1759 }
1760
1761 // AABB fallback (issue #635): when CSG can't subtract the
1762 // opening (most commonly because its triangulated profile
1763 // exceeds `MAX_CSG_POLYGONS_PER_MESH`, i.e. circular /
1764 // arched / arbitrary curved openings), cut the opening's
1765 // axis-aligned bounding box instead. This leaves a square
1766 // hole in place of a round one, but a square hole is
1767 // dramatically less wrong than a missing void on a wall
1768 // that is supposed to host a window or door.
1769 if !csg_succeeded {
1770 let dir = extrusion_dir.or_else(|| {
1771 Some(wall_thinnest_axis_dir(&wall_min, &wall_max))
1772 });
1773 let (final_min, final_max) = if let Some(dir) = dir {
1774 self.extend_opening_along_direction(
1775 *open_min_pt,
1776 *open_max_pt,
1777 wall_min,
1778 wall_max,
1779 dir,
1780 )
1781 } else {
1782 (*open_min_pt, *open_max_pt)
1783 };
1784 // Near-engulf guard. When CSG returned the host *unchanged*
1785 // (no cut) AND the opening's AABB covers the whole wall on
1786 // every axis, the rectangular fallback would cut that
1787 // engulfing box and delete the wall. This is the signature
1788 // of a non-rectangular opening whose bounding box engulfs
1789 // the host while its real profile excludes it: the kernel
1790 // errors on the grazing/coplanar cutter and returns the
1791 // un-cut host, which is already the correct result vs
1792 // IfcOpenShell (advanced #555433's facade-scale void
1793 // #555493). Keep the un-cut host instead of over-cutting.
1794 // Normal windows/doors — including the issue-635 high-poly
1795 // round openings the AABB box approximates — sit INSIDE the
1796 // wall and never engulf it, so they still take the fallback.
1797 // The 3% per-axis tolerance absorbs an opening that reaches
1798 // ~flush with a wall face (its near plane).
1799 let engulfs_host = {
1800 let tol = ENGULF_TOLERANCE;
1801 let covers = |omin: f64, omax: f64, wmin: f64, wmax: f64| {
1802 let slack = (wmax - wmin).abs().max(1.0e-9) * tol;
1803 omin <= wmin + slack && omax >= wmax - slack
1804 };
1805 covers(final_min.x, final_max.x, wall_min.x, wall_max.x)
1806 && covers(final_min.y, final_max.y, wall_min.y, wall_max.y)
1807 && covers(final_min.z, final_max.z, wall_min.z, wall_max.z)
1808 };
1809 // `capped` keys on the `OperandTooLarge` rejection: the
1810 // #1109 per-boolean/-element escalation budget records it
1811 // when a cut trips mid-arrangement and bails to the un-cut
1812 // host (`csg/mod.rs`), so it is NOT always false — a
1813 // grazing engulfing cutter that would run long trips the
1814 // budget just as it errors on the coincident faces.
1815 // Engulf suppression therefore applies regardless of WHY
1816 // the CSG produced no change (budget trip or kernel error
1817 // on the coincident faces): an engulfing cutter's AABB
1818 // covers the whole host, so the #635 box-cut would delete
1819 // the entire wall — a silent element deletion strictly
1820 // worse than leaving a phantom (un-cut) solid. `capped` is
1821 // now only read by the diagnostic below (it no longer
1822 // gates the suppression), so it is unused in a release
1823 // build that compiles out both the tracing and the
1824 // debug/test `eprintln!` legs of `diag_warn!`.
1825 #[allow(unused_variables)]
1826 let capped = clipper.has_operand_too_large_since(failures_before);
1827 // Issue #964: suppress the destructive AABB box when the
1828 // host already has this void cut into it (a void
1829 // double-encoded as both a profile inner curve and a
1830 // redundant IfcOpeningElement). When every column through
1831 // the opening footprint is already open in the host,
1832 // cutting the bounding box would replace a correct
1833 // round/polygonal hole with a rectangle. Unlike the
1834 // engulf heuristic this is a positive void detection, so
1835 // it overrides `capped` too (the void demonstrably
1836 // exists — there is nothing left to approximate).
1837 let probe_axis = dir.unwrap_or_else(|| {
1838 wall_thinnest_axis_dir(&wall_min, &wall_max)
1839 });
1840 let redundant_void =
1841 opening_redundant_with_host(&result, opening_mesh, &probe_axis);
1842 let suppress_fallback =
1843 redundant_void || (csg_unchanged && engulfs_host);
1844 if !suppress_fallback {
1845 // Diagnostic for issue #635: log the opening
1846 // triangle count when the AABB fallback actually
1847 // fires, so round windows (post profile
1848 // simplification) can be confirmed to hit CSG and
1849 // only genuinely-uncut voids land on the box cut.
1850 // A genuine degraded mode, so warn-level under
1851 // observability; the legacy debug/test-only
1852 // eprintln is preserved otherwise.
1853 crate::diag::diag_warn!(
1854 { opening_tris = opening_mesh.triangle_count(),
1855 reason = "csg-produced-no-change",
1856 "voids: AABB fallback cut used for opening" }
1857 else {
1858 #[cfg(any(debug_assertions, test))]
1859 eprintln!(
1860 "[issue-635] AABB fallback used: opening={} tris (CSG produced no change)",
1861 opening_mesh.triangle_count()
1862 );
1863 }
1864 );
1865 // Deliberate degraded mode: this fallback removes
1866 // the wall material inside the opening AABB but no
1867 // longer emits reveal/recess quads (deleted with
1868 // the legacy clip path), so its output has an open
1869 // rim. Acceptable for a safety net that fired 0x
1870 // across the regression corpus — the exact-kernel
1871 // path ahead of it emits the reveals itself.
1872 let aabb_cut =
1873 self.cut_rectangular_opening(&result, final_min, final_max);
1874 if !aabb_cut.is_empty() && aabb_cut.triangle_count() != tri_before {
1875 result = aabb_cut;
1876 host_mutated = true;
1877 }
1878 } else {
1879 // The engulfing/redundant-void guard suppressed the
1880 // #635 AABB box-cut. For an engulfing cutter the box
1881 // covers the whole host, so cutting it would DELETE
1882 // the wall; leaving the host un-cut (a phantom solid)
1883 // is the strictly safer degrade. `capped` records
1884 // whether the CSG bailed via the #1109 budget trip
1885 // (`OperandTooLarge`) vs a kernel error on the
1886 // coincident faces.
1887 crate::diag::diag_warn!(
1888 { opening_tris = opening_mesh.triangle_count(),
1889 capped = capped,
1890 reason = "engulfing-cutter-fallback-suppressed",
1891 "voids: AABB fallback suppressed for engulfing cutter (host left un-cut)" }
1892 else {
1893 #[cfg(any(debug_assertions, test))]
1894 eprintln!(
1895 "[issue-635] AABB fallback SUPPRESSED for engulfing cutter: opening={} tris, capped={} (host left un-cut)",
1896 opening_mesh.triangle_count(),
1897 capped
1898 );
1899 }
1900 );
1901 }
1902 }
1903 }
1904 }
1905 }
1906
1907 // NOTE (issue #635): the clipping planes from `IfcBooleanClippingResult`
1908 // are already applied by `BooleanClippingProcessor::process` during
1909 // `process_element` — the post-clip mesh is the *input* to this
1910 // function. Re-clipping here was a leftover from before that
1911 // processor existed; for `IfcPolygonalBoundedHalfSpace` it actively
1912 // *broke* gable walls, because `extract_half_space_plane` discards
1913 // the polygonal bound and the resulting unbounded plane chops off
1914 // the gable peak. Voids alone are applied here.
1915
1916 // Drain whatever fallbacks the kernel logged during this element's
1917 // void / clip pass, attribute them to the host product, and stash on
1918 // the router so the caller can surface them (e.g. flagged in a
1919 // viewer overlay or asserted in regression tests).
1920 let kernel_failures = clipper.take_failures();
1921 if !kernel_failures.is_empty() {
1922 self.record_host_failure_summary(element_id, &kernel_failures);
1923 self.record_csg_failures(element_id, kernel_failures);
1924 }
1925
1926 // WATERTIGHT SLIVER REFINEMENT (issue #1007): the exact-kernel cut of a
1927 // long, tilted faceted-BREP host facet can emit a high-aspect corner
1928 // sliver (a far-corner triangle fanned to two new rim vertices a few cm
1929 // apart) that lands ALONE in its plane bucket and so bypasses the
1930 // coplanar CDT. Bisect any >8:1 triangle's longest edge at its midpoint,
1931 // splitting BOTH incident triangles in lockstep so the mesh stays
1932 // watertight (no T-junction) and the midpoint lies ON the original edge
1933 // ⇒ cut volume is preserved exactly. A no-op on clean cuts (no triangle
1934 // exceeds 8:1), so it does not perturb the frozen corpus. Only runs when
1935 // a cut was actually attempted (`!ctx.is_noop()` guarantees this path).
1936 let mut result = crate::facet_weld::refine_high_aspect_slivers(&result);
1937
1938 // UNDER-CUT REPAIR: a self-intersecting tessellated cutter (garbage
1939 // vertices metres from the real opening) makes the kernel UNDER-cut — a
1940 // wall flap bridges the opening. Re-cut each such opening with a clean
1941 // box (removes only the opening prism, taking the flap, while preserving
1942 // and re-triangulating the wall around the hole). Done HERE, in the cut
1943 // frame (host + cutters share it), and BEFORE the spike clip so any
1944 // residual protrusion is still caught. A no-op when no cutter is
1945 // malformed — clean openings are never reshaped.
1946 recut_malformed_openings(&mut result, &ctx.malformed_opening_boxes());
1947
1948 // SPURIOUS-FLAP CLIP: a subtract can only remove material, so the cut is
1949 // mathematically contained in the host's pre-cut AABB (`wall_min/max`);
1950 // any triangle poking past it is provably an artifact — a malformed
1951 // cutter's far-flung leaked flap (self-intersecting / tessellated void,
1952 // surfacing once a SECOND cutter perturbs the arrangement), OR the
1953 // flush-cap through-extension reveal overhang (`extend_opening_mesh_
1954 // through_host` pushes a flush cap ~0.3·depth past the host for a clean
1955 // transversal cut, so the subtract emits the reveal out to it — 0.105 m
1956 // past a 0.35 m floor slab, #1633). `clip_triangles_to_host_aabb` bounds
1957 // its jitter tolerance so a large host cannot leak the overhang. A no-op
1958 // on clean cuts.
1959 result.clip_triangles_to_host_aabb(
1960 [wall_min.x as f32, wall_min.y as f32, wall_min.z as f32],
1961 [wall_max.x as f32, wall_max.y as f32, wall_max.z as f32],
1962 );
1963
1964 // STRAY-SHARD SWEEP: see `sweep::drop_faces_outside_host` (#1788).
1965 if host_mutated {
1966 result = drop_faces_outside_host(result, &original_host);
1967 }
1968
1969 // Per-host cut-effect snapshot: tris_before / tris_after lets the
1970 // diagnostic surface the silent-no-op case (rectangular boxes
1971 // processed but the host mesh came out unchanged — the box
1972 // probably didn't intersect the wall, e.g. wrong placement).
1973 self.record_host_cut_effect(
1974 element_id,
1975 tris_before,
1976 result.triangle_count(),
1977 synth_rect.len(),
1978 host_bounds_capture,
1979 );
1980
1981 result
1982 }
1983
1984 /// Process an element into per-item sub-meshes with opening subtraction.
1985 ///
1986 /// Mirrors [`process_element_with_voids`] but preserves each
1987 /// `IfcShapeRepresentation` item as its own sub-mesh so that callers can
1988 /// look up a direct `IfcStyledItem` color per geometry item (e.g. the
1989 /// three extrusion layers of a multi-layer wall). The opening(s) are
1990 /// subtracted from each sub-mesh independently so that windows and doors
1991 /// cut through every material layer they intersect.
1992 ///
1993 /// Returns an empty collection when there are no openings (callers should
1994 /// fall back to [`process_element_with_submeshes`]) or when every
1995 /// sub-mesh is destroyed by void subtraction.
1996 pub fn process_element_with_submeshes_and_voids(
1997 &self,
1998 element: &DecodedEntity,
1999 decoder: &mut EntityDecoder,
2000 void_index: &FxHashMap<u32, Vec<u32>>,
2001 ) -> Result<SubMeshCollection> {
2002 // Layered single-solid path: slice the element's base mesh by its
2003 // material-layer buildup AFTER subtracting voids. This produces one
2004 // sub-mesh per layer keyed by IfcMaterial id, so layers show up as
2005 // individual colors even when the underlying geometry is a single
2006 // swept solid.
2007 if let Some(layered) = self.try_layered_sub_meshes(element, decoder, Some(void_index)) {
2008 return Ok(layered);
2009 }
2010
2011 let opening_ids = match void_index.get(&element.id) {
2012 Some(ids) if !ids.is_empty() => ids.clone(),
2013 _ => return Ok(SubMeshCollection::new()),
2014 };
2015
2016 // Voided occurrences materialize cut geometry (#1623 don't-bake off) with no
2017 // texture index (#1781: CSG rebuilds vertices, orphaning UVs — colour wins).
2018 let sub_meshes = self.process_element_with_submeshes_impl(element, decoder, false, None)?;
2019 if sub_meshes.is_empty() {
2020 return Ok(SubMeshCollection::new());
2021 }
2022
2023 // Classify openings + resolve clipping planes ONCE per element. Doing
2024 // this per sub-mesh would re-run `process_element` on every opening
2025 // and re-extract clipping planes N times, multiplying the expensive
2026 // parsing/CSG setup by the sub-mesh count on the exact elements this
2027 // path targets (multi-layer walls with windows).
2028 let ctx = self.build_void_context(element, &opening_ids, decoder);
2029
2030 let mut voided = SubMeshCollection::new();
2031 for sub in sub_meshes.sub_meshes {
2032 let geometry_id = sub.geometry_id;
2033 let mut voided_mesh = self.apply_void_context(sub.mesh, &ctx, element.id);
2034 // Same CSG-seam hygiene as the single-mesh void path.
2035 voided_mesh.clean_degenerate();
2036 if !voided_mesh.is_empty() {
2037 voided
2038 .sub_meshes
2039 .push(SubMesh::new(geometry_id, voided_mesh));
2040 }
2041 }
2042
2043 Ok(voided)
2044 }
2045}
2046
2047#[cfg(test)]
2048mod flap_clip_tests;