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;
19pub(crate) mod 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 // Two host faces sharing an edge compute the same new crossing
968 // point through different arithmetic; unify them at ulp scale so
969 // the split face's halves share their seam.
970 let cut = prism_cut::dedup_cut_vertices(&cut, &mesh);
971 return match residual {
972 None => {
973 // Same per-host cut-effect snapshot the exact path
974 // records, so prism-cut hosts aren't missing from the
975 // diagnostics.
976 self.record_host_cut_effect(
977 element_id,
978 prism_tris_before,
979 cut.triangle_count(),
980 ctx.merged_openings.len(),
981 prism_bounds,
982 );
983 cut
984 }
985 // With a residual, the recursive exact pass below records
986 // the (final) cut-effect snapshot itself.
987 Some(res) => self.apply_void_context(cut, &res, element_id),
988 };
989 }
990 }
991
992 // WORLD-space host AABB for the per-host diagnostic (`bbox`), captured
993 // HERE — before the local-frame branch below zeroes `mesh.origin` and
994 // before `try_cut_wall_local_frame` rotates the mesh into its own frame.
995 // On the wasm/local-frame default the host is stored relative to a
996 // nonzero `mesh.origin` (world = origin + position), so bounds taken
997 // after the clear would be local/near-zero and misdirect
998 // `diagnose-geometry --product/--type` (the #1474 local-frame-consumer
999 // bug class: every world-space reader must fold `MeshData.origin`). The
1000 // origin is folded in f64 so georeferenced hosts report true world
1001 // coords, then cast to the f32 diagnostic surface.
1002 let host_world_bounds = world_host_bounds(&mesh);
1003
1004 let origin = mesh.origin;
1005 if origin == [0.0, 0.0, 0.0] && ctx.all_cutters_world_framed() {
1006 // Legacy/world frame on host AND cutters: no relativization needed.
1007 return self.apply_void_context_inner(mesh, ctx, element_id, host_world_bounds);
1008 }
1009 // Work entirely in the host's local frame (origin 0 on every operand).
1010 // `relativized_by` folds each cutter's OWN origin and subtracts the host
1011 // origin in f64, so a per-element local-frame opening lands at the host
1012 // precisely. This also covers a host that snapped to origin 0 while its
1013 // openings did not (origin == 0 but cutters are local-framed).
1014 mesh.origin = [0.0, 0.0, 0.0];
1015 let local_ctx = ctx.relativized_by(origin);
1016 let mut result =
1017 self.apply_void_context_inner(mesh, &local_ctx, element_id, host_world_bounds);
1018 result.origin = origin;
1019 result
1020 }
1021
1022 /// Try the analytic rectangular-opening fast path. Returns the watertight
1023 /// cut mesh iff EVERY merged opening is an axis-aligned `Rectangular`
1024 /// through-cut and the host is a clean axis-aligned box; otherwise `None`
1025 /// (→ the exact kernel handles it). A mixed opening set defers the whole
1026 /// host rather than composing analytic + exact cuts.
1027 fn try_rect_fast(&self, host: &Mesh, ctx: &VoidContext) -> Option<Mesh> {
1028 let (wmn, wmx) = host.bounds();
1029 let wall_min = Point3::new(wmn.x as f64, wmn.y as f64, wmn.z as f64);
1030 let wall_max = Point3::new(wmx.x as f64, wmx.y as f64, wmx.z as f64);
1031 let mut boxes: Vec<([f64; 3], [f64; 3])> =
1032 Vec::with_capacity(ctx.merged_openings.len());
1033 for op in &ctx.merged_openings {
1034 match op {
1035 OpeningType::Rectangular(omn, omx, dir) => {
1036 let (fmn, fmx) = match dir {
1037 Some(d) => self.extend_opening_along_direction(
1038 *omn, *omx, wall_min, wall_max, *d,
1039 ),
1040 None => (*omn, *omx),
1041 };
1042 boxes.push(([fmn.x, fmn.y, fmn.z], [fmx.x, fmx.y, fmx.z]));
1043 }
1044 _ => return None,
1045 }
1046 }
1047 let mut stats = crate::rect_fast::RectFastStats::default();
1048 let out = crate::rect_fast::subtract_rect_openings(host, &boxes, &mut stats);
1049 self.record_rect_fast(&stats);
1050 // The cellular cut conformingly splits EVERY face by ALL grid lines (so
1051 // adjacent cells share edges → watertight), which over-fragments faces an
1052 // opening doesn't reach. Run the result through the SAME coplanar merge
1053 // the exact path uses (i_overlay union per plane) to collapse those back
1054 // to minimal triangles — keeps it watertight and un-bloated.
1055 out.map(crate::csg::ClippingProcessor::consolidate_coplanar)
1056 }
1057
1058 /// Cut a plan-rotated wall's openings in the wall's own axis-aligned,
1059 /// origin-centred frame (issue #1167). Returns `None` (caller uses the
1060 /// world path) unless an opening supplies a non-axis-aligned, ~horizontal
1061 /// depth axis (a vertical wall rotated in plan) and every opening carries a
1062 /// cutter mesh.
1063 ///
1064 /// In the wall frame the host and its openings are axis-aligned and near the
1065 /// origin, so the exact subtract runs in the clean, f32-precise regime a
1066 /// straight wall enjoys — clean-box openings even reclassify to the
1067 /// watertight `rect_fast` path. Curved / brep openings keep their mesh and
1068 /// are subtracted there too. The result is rotated back; the orthonormal,
1069 /// origin-centred round-trip is identity for untouched geometry. Recursing
1070 /// into [`Self::apply_void_context_inner`] is safe: in the frame every
1071 /// opening's depth is +Z (axis-aligned), so this guard returns `None` on the
1072 /// inner call.
1073 fn try_cut_wall_local_frame(
1074 &self,
1075 mesh: &Mesh,
1076 ctx: &VoidContext,
1077 element_id: u32,
1078 host_world_bounds: ((f32, f32, f32), (f32, f32, f32)),
1079 ) -> Option<Mesh> {
1080 if ctx.merged_openings.is_empty() {
1081 return None;
1082 }
1083 let depth_of = |op: &OpeningType| -> Option<Vector3<f64>> {
1084 match op {
1085 OpeningType::DiagonalRectangular(_, f) => Some(f.depth),
1086 OpeningType::NonRectangular(_, _, _, d) => *d,
1087 OpeningType::Rectangular(_, _, d) => *d,
1088 }
1089 };
1090 // Define the wall frame from the first opening whose depth is a
1091 // genuinely rotated, ~horizontal axis. Axis-aligned walls find none and
1092 // keep their (unchanged) world path.
1093 let axes = ctx
1094 .merged_openings
1095 .iter()
1096 .filter_map(depth_of)
1097 .find(|d| !is_axis_aligned_direction(d) && d.z.abs() <= 0.2)
1098 .and_then(wall_frame_from_depth)?;
1099
1100 // AABB-only `Rectangular` openings can't be rotated into the frame; a
1101 // plan-rotated wall never has them (they'd be diagonal), so bail.
1102 if ctx
1103 .merged_openings
1104 .iter()
1105 .any(|op| matches!(op, OpeningType::Rectangular(..)))
1106 {
1107 return None;
1108 }
1109
1110 let (mn, mx) = mesh.bounds();
1111 let center = Vector3::new(
1112 ((mn.x + mx.x) * 0.5) as f64,
1113 ((mn.y + mx.y) * 0.5) as f64,
1114 ((mn.z + mx.z) * 0.5) as f64,
1115 );
1116 let host_local = mesh_to_frame(mesh, &axes, center);
1117
1118 let z = Vector3::new(0.0, 0.0, 1.0);
1119 let mut local_openings: Vec<OpeningType> = Vec::with_capacity(ctx.merged_openings.len());
1120 for op in &ctx.merged_openings {
1121 let cutter = match op {
1122 OpeningType::DiagonalRectangular(m, _) => m,
1123 OpeningType::NonRectangular(m, _, _, _) => m,
1124 OpeningType::Rectangular(..) => return None,
1125 };
1126 let (lmn, lmx) = project_aabb_in_frame(cutter, &axes, center)?;
1127 let in_frame =
1128 |v: Vector3<f64>| Vector3::new(v.dot(&axes[0]), v.dot(&axes[1]), v.dot(&axes[2]));
1129 // The cutter's own penetration (depth) axis expressed in the wall
1130 // frame. Drives both the alignment test and — for the exact fallback
1131 // — the cap-extension direction, which MUST stay faithful to THIS
1132 // cutter: hardcoding the wall normal would push a misaligned
1133 // opening's flush/short cap along the wrong axis and carve the wrong
1134 // prism (#1270 review).
1135 let frame_depth = match op {
1136 OpeningType::DiagonalRectangular(_, f) => Some(in_frame(f.depth)),
1137 OpeningType::NonRectangular(_, _, _, d) => d.map(in_frame),
1138 OpeningType::Rectangular(..) => None,
1139 };
1140 // Whether THIS opening's own oriented box is axis-aligned in the
1141 // wall frame. The frame is seeded from the FIRST rotated opening
1142 // (#1167); a second opening tilted differently *in plane* is NOT
1143 // axis-aligned here, so projecting its frame AABB would over-cut it
1144 // (#1259 review). Only a clean box whose full frame aligns with the
1145 // wall frame may take the fast rectangular cut; everything else —
1146 // brep/curved voids and frame-misaligned boxes alike — keeps its
1147 // exact (un-rotated, centred) mesh for the subtract.
1148 let frame_aligned = match op {
1149 OpeningType::DiagonalRectangular(_, f) => {
1150 is_axis_aligned_direction(&in_frame(f.depth))
1151 && is_axis_aligned_direction(&in_frame(f.cross_a))
1152 && is_axis_aligned_direction(&in_frame(f.cross_b))
1153 }
1154 _ => false,
1155 };
1156 if frame_aligned {
1157 local_openings.push(OpeningType::Rectangular(lmn, lmx, Some(z)));
1158 } else {
1159 let mesh_local = mesh_to_frame(cutter, &axes, center);
1160 // Keep this cutter's true depth in the frame; fall back to the
1161 // wall normal (+Z) only when the opening carried no direction.
1162 let dir = frame_depth.unwrap_or(z);
1163 local_openings.push(OpeningType::NonRectangular(mesh_local, lmn, lmx, Some(dir)));
1164 }
1165 }
1166 let local_ctx = VoidContext {
1167 merged_openings: Self::merge_rectangular_openings(&local_openings),
1168 openings: local_openings,
1169 // The local-frame recursion never re-captures the parametric cut
1170 // (issue #1209): it operates on already-classified openings, so the
1171 // analytic `param` / 2D paths are irrelevant here — defer to the exact
1172 // path.
1173 param: None,
1174 bool2d: None,
1175 };
1176
1177 // Forward the WORLD host bounds captured before this rotation so the
1178 // diagnostic reports world coords, not wall-frame (rotated/centred) ones.
1179 let result_local =
1180 self.apply_void_context_inner(host_local, &local_ctx, element_id, host_world_bounds);
1181 Some(mesh_from_frame(&result_local, &axes, center))
1182 }
1183
1184 // `host_mutated` is set just before an early `break`, so the final write is
1185 // intentionally never read back; keep the flag for readability of the branch.
1186 #[allow(unused_assignments)]
1187 fn apply_void_context_inner(
1188 &self,
1189 mesh: Mesh,
1190 ctx: &VoidContext,
1191 element_id: u32,
1192 host_bounds_capture: ((f32, f32, f32), (f32, f32, f32)),
1193 ) -> Mesh {
1194 // Capture the input triangle count so the per-host diagnostic can flag
1195 // the "cuts attempted but produced no change" case — the silent-no-op
1196 // signature when an opening box doesn't intersect the host mesh. The
1197 // WORLD-space host AABB (`host_bounds_capture`) is passed in from
1198 // `apply_void_context` (captured before the origin clear / frame
1199 // rotation), not recomputed here from the possibly local-framed `mesh`.
1200 let tris_before = mesh.triangle_count();
1201 if ctx.is_noop() {
1202 return mesh;
1203 }
1204 let original_host = mesh.clone(); // stray-shard sweep parity reference
1205
1206 // LOCAL-FRAME CUT (issue #1167): a vertical wall rotated in plan is cut
1207 // in its own axis-aligned, origin-centred frame — where the exact
1208 // subtract is clean and f32-precise — then the result is rotated back.
1209 // The world-space tilted cut at large coordinates over-cuts and
1210 // fragments badly. Scoped to plan-rotated walls; everything else falls
1211 // through to the world path unchanged.
1212 if let Some(cut) = self.try_cut_wall_local_frame(&mesh, ctx, element_id, host_bounds_capture)
1213 {
1214 return cut;
1215 }
1216
1217 let clipper = ClippingProcessor::new();
1218 // ROOT-CAUSE FIX (issue #1007, host #1112): correct f32 facet jitter on
1219 // the host triangle soup BEFORE the exact-kernel cut. A faceted-BREP
1220 // roof slope authored as ONE flat plane comes back from the f32 import
1221 // with adjacent facets ~0.09° non-coplanar. That jitter (a) splits the
1222 // slope into many one-triangle plane buckets in `consolidate_coplanar`
1223 // — a single-triangle bucket bypasses the CDT and is emitted as a 25:1
1224 // far-corner sliver fan — and (b) blocks clean coalescing of the cut
1225 // hole. Welding near-coplanar adjacent facets (≤0.15°, well below any
1226 // real roof pitch) to a single least-squares plane makes the slope
1227 // EXACTLY coplanar, so the cut emits one CDT-refined region (rim sliver
1228 // gone) with a clean opening hole. Deterministic + watertight + grid-
1229 // snapped; a no-op for already-planar extrusion hosts.
1230 let mut result = crate::facet_weld::weld_near_coplanar_facets(&mesh);
1231
1232 // ANALYTIC FAST PATH: an axis-aligned box host whose openings are ALL
1233 // axis-aligned rectangular through-cuts is subtracted analytically
1234 // (`rect_fast`), skipping the exact mesh-arrangement kernel — the
1235 // ~80 s memory-bandwidth-bound void-cut window's dominant cost. Pure
1236 // optimization: any precondition miss (non-box host, mixed/non-rect
1237 // openings, near-edge feature) returns `None` and the host falls through
1238 // to the exact path below unchanged. Fired BEFORE the dense-host
1239 // subdivision (that workaround is only for the exact kernel).
1240 if crate::rect_fast::enabled() {
1241 if let Some(fast) = self.try_rect_fast(&result, ctx) {
1242 // Same per-host cut-effect snapshot the exact path records below,
1243 // so fast-path hosts aren't missing from the diagnostics.
1244 self.record_host_cut_effect(
1245 element_id,
1246 tris_before,
1247 fast.triangle_count(),
1248 ctx.merged_openings.len(),
1249 host_bounds_capture,
1250 );
1251 return fast;
1252 }
1253 }
1254
1255 // OPENING-DENSE HOST REFINEMENT: when many openings target the same host
1256 // (a window wall is usually 2 big face-triangles per side), every cut's
1257 // intersection segments pile onto those few triangles — the exact
1258 // arrangement then re-triangulates a single triangle carrying dozens of
1259 // constraints (O(k²)), and the batched N-ary subtract leaves unrecovered
1260 // constraints and degrades to the O(N²) sequential path. Pre-subdividing
1261 // the host spreads the segments across many small triangles (small k each)
1262 // so the batched cut recovers. `consolidate_coplanar` re-triangulates each
1263 // coplanar group afterwards, so the temporary interior vertices don't
1264 // bloat the final mesh. Levels are scaled to opening count and capped.
1265 let n_openings = ctx.merged_openings.len();
1266 if n_openings >= 8 {
1267 // Just enough subdivision that each host triangle carries only a few
1268 // intersection segments, so the batched N-ary subtract RECOVERS rather
1269 // than degrading to the O(N²) sequential path — that recovery is the
1270 // win (≈10× on the densest walls), not the per-triangle segment count
1271 // itself. Over-subdividing is counter-productive: the extra triangles
1272 // cost more in the arrangement than the spreading saves (level 3 was
1273 // ~3× slower than level 1 on a 14-opening wall). Aim for ≳ a handful of
1274 // host triangles per opening, capped at 2 levels.
1275 let host_tris = result.triangle_count().max(1);
1276 let target = 4 * n_openings;
1277 let mut levels = 0usize;
1278 while host_tris * (1usize << (2 * (levels + 1))) < target && levels < 2 {
1279 levels += 1;
1280 }
1281 // A COARSE host (a box wall is ~12 triangles) still needs one split
1282 // even when the next level would overshoot the target; an ALREADY-dense
1283 // host (e.g. a faceted-BREP wall whose triangle count already meets the
1284 // target) is left untouched — extra geometry there only slows the cut.
1285 if levels == 0 && host_tris < target {
1286 levels = 1;
1287 }
1288 if levels > 0 {
1289 result = result.subdivided(levels);
1290 }
1291 }
1292
1293 let (wall_min_f32, wall_max_f32) = result.bounds();
1294 let wall_min = Point3::new(
1295 wall_min_f32.x as f64,
1296 wall_min_f32.y as f64,
1297 wall_min_f32.z as f64,
1298 );
1299 let wall_max = Point3::new(
1300 wall_max_f32.x as f64,
1301 wall_max_f32.y as f64,
1302 wall_max_f32.z as f64,
1303 );
1304
1305 let wall_valid = !result.is_empty()
1306 && result.positions.iter().all(|&v| v.is_finite())
1307 && result.triangle_count() >= 4;
1308
1309 if !wall_valid {
1310 return result;
1311 }
1312
1313 // NOTE: there is deliberately NO per-element CSG operation budget here.
1314 // The BSP-era `MAX_CSG_OPERATIONS = 10` cap silently skipped the 11th+
1315 // opening (it `continue`d past BOTH the exact subtract AND the #635 AABB
1316 // fallback), which is exactly the regression `csg_void_test::
1317 // many_tessellated_box_openings_are_all_cut` pins (history: #413/#439).
1318 // On the unified exact path every opening is a cheap box-vs-host cut, so
1319 // a budget-skipped opening is a correctness bug, not a perf guard.
1320
1321 // UNIFIED EXACT PATH (PART B): every opening — axis-aligned RECTANGULAR
1322 // included — is now subtracted by the exact mesh kernel, NOT the legacy
1323 // Sutherland-Hodgman AABB clip. A `Rectangular` opening is materialised as
1324 // a PENETRATING box mesh (its bounds extended through the wall along the
1325 // extrusion axis by `extend_opening_along_direction`, so both caps poke past
1326 // the host ⇒ a transversal cut with no flush-cap sliver — the same robust
1327 // condition PART A guarantees for tilted openings). The exact subtract emits
1328 // the void's interior reveal faces itself, so the explicit reveal/recess
1329 // quad generators are no longer needed on this path.
1330 //
1331 // `synth_rect` owns the synthesised box meshes so they outlive the loop's
1332 // borrowed `&OpeningType`s below.
1333 let mut synth_rect: Vec<OpeningType> = Vec::new();
1334 let mut non_rect_openings: Vec<&OpeningType> = Vec::new();
1335 for opening in &ctx.merged_openings {
1336 match opening {
1337 OpeningType::Rectangular(open_min, open_max, extrusion_dir) => {
1338 // Penetration axis: the authored extrusion dir when present,
1339 // else inferred from how the box pierces the host (issue
1340 // #1337). Carrying a concrete dir downstream keeps the
1341 // through-host cap-flush extension off the opening's thinnest
1342 // (in-plane) axis for deep cutters.
1343 let dir = extrusion_dir.unwrap_or_else(|| {
1344 infer_box_penetration_dir(open_min, open_max, &wall_min, &wall_max)
1345 });
1346 // Only openings with an AUTHORED extrusion dir were extended
1347 // here before; keep the synthesized box identical for the
1348 // dirless case (the inferred dir only steers the later
1349 // through-host extension, not the box bounds).
1350 let (final_min, final_max) = if extrusion_dir.is_some() {
1351 self.extend_opening_along_direction(
1352 *open_min, *open_max, wall_min, wall_max, dir,
1353 )
1354 } else {
1355 (*open_min, *open_max)
1356 };
1357 let box_mesh = Self::make_box_mesh(final_min, final_max);
1358 synth_rect.push(OpeningType::NonRectangular(
1359 box_mesh,
1360 final_min,
1361 final_max,
1362 Some(dir),
1363 ));
1364 }
1365 // A MALFORMED (self-intersecting) tessellated cutter is NOT cut
1366 // here: its messy mesh under-cuts the opening, and double-cutting
1367 // it (here AND in the clean-box `recut_malformed_openings` pass)
1368 // leaves overlapping sliver "shards" in the reveals. Skip it; the
1369 // recut performs the single, clean box cut for these openings.
1370 OpeningType::NonRectangular(m, ..) if opening_obb_if_malformed(m).is_some() => {}
1371 other => non_rect_openings.push(other),
1372 }
1373 }
1374 let all_openings: Vec<&OpeningType> =
1375 synth_rect.iter().chain(non_rect_openings.iter().copied()).collect();
1376
1377 // DISJOINT-CUTTER BATCHING: group cutters whose pad-inflated AABBs are
1378 // pairwise disjoint and subtract each group in ONE conforming arrangement
1379 // (`ClippingProcessor::subtract_mesh_many`). Sequential per-opening
1380 // subtraction re-arranges the (growing) host once per cutter, and each
1381 // intermediate f64→f32→snap round-trip re-jitters carve vertices off
1382 // shared planes so cut N+1 re-cracks what cut N reconciled (many-void
1383 // walls' compounding open edges, the 16-void slab's ~3.5 s cost). Batching
1384 // admits only openings passing the SAME guards as the sequential loop plus
1385 // per-component watertightness (#2176). Singletons and any group whose
1386 // batched cut fails its guards — or the kernel conformity gate
1387 // (`subtract_mesh_many` rejects a group whose N-ary arrangement left an
1388 // unrecovered constraint) — fall through to the per-opening sequential
1389 // loop below with its full #635 fallback / engulf / redundant-void machinery.
1390 let mut batch_consumed: Vec<bool> = vec![false; all_openings.len()];
1391 // Disjoint groups of opening indices (len ≥ 2 only); each is cut
1392 // INLINE at its first member's position in the sequential loop below,
1393 // so the relative order of batched vs sequential cutters matches the
1394 // pure sequential pass — only the order WITHIN a group (mutually
1395 // disjoint cutters, the provably order-free case) is collapsed.
1396 let mut batch_groups: Vec<Vec<(usize, Mesh)>> = Vec::new();
1397 let mut batch_group_of: FxHashMap<usize, usize> = FxHashMap::default();
1398 // Set on every successful cut; while false, a group's admission-time
1399 // extended cutters are still valid and reused verbatim.
1400 let mut host_mutated = false;
1401
1402 // COAXIAL FOOTPRINT-UNION for OVERLAPPING clusters (issue #129, flag
1403 // `IFC_LITE_VOID_UNION`). Overlapping cutters can't join the disjoint batch
1404 // below and otherwise fall to the O(N) sequential exact path; this fuses
1405 // each overlapping coaxial cluster into disjoint re-extruded prisms and cuts
1406 // them in one `subtract_mesh_many`. See `coaxial_union`. Marks consumed
1407 // openings so the batch + sequential loops skip them; a cluster that fails
1408 // any guard is left unconsumed for the exact path (never worse than exact).
1409 self.coaxial_union_prepass(
1410 &mut result,
1411 &all_openings,
1412 &mut batch_consumed,
1413 &mut host_mutated,
1414 &clipper,
1415 );
1416
1417 if all_openings.len() >= 2 {
1418 // Inflation pad: ≥ 2×(promote band 8·2⁻¹⁶ ≈ 122 µm + snap radius);
1419 // 1 mm is conservative and far below any real opening separation.
1420 // Touching/overlapping cutters land in DIFFERENT groups, cut in
1421 // sequence — overlap degrades gracefully to sequential behavior.
1422 const BATCH_PAD: f64 = 1.0e-3;
1423 struct Cand {
1424 idx: usize,
1425 /// The admission-time extended cutter (host PRE-cut). Reused at
1426 /// cut time while the host is still unmutated — the common case
1427 /// (groups are cut at their FIRST member, usually before any
1428 /// sequential cut) — so extension isn't paid twice.
1429 mesh: Mesh,
1430 lo: [f64; 3],
1431 hi: [f64; 3],
1432 }
1433 let mut cands: Vec<Cand> = Vec::new();
1434 for (idx, opening) in all_openings.iter().enumerate() {
1435 // Already cut by the coaxial/overlap-union prepass above.
1436 if batch_consumed[idx] {
1437 continue;
1438 }
1439 let norm: Option<(&Mesh, Option<Vector3<f64>>)> = match **opening {
1440 OpeningType::Rectangular(..) => None,
1441 OpeningType::DiagonalRectangular(ref m, ref f) => Some((m, Some(f.depth))),
1442 OpeningType::NonRectangular(ref m, _, _, ref d) => Some((m, *d)),
1443 };
1444 let Some((opening_mesh, extrusion_dir)) = norm else { continue };
1445 // Same admission guards as the sequential loop.
1446 let opening_valid = !opening_mesh.is_empty()
1447 && opening_mesh.positions.iter().all(|&v| v.is_finite())
1448 && opening_mesh.positions.len() >= 9;
1449 if !opening_valid {
1450 continue;
1451 }
1452 let (result_min, result_max) = result.bounds();
1453 let (omn, omx) = opening_mesh.bounds();
1454 let no_overlap = omx.x < result_min.x
1455 || omn.x > result_max.x
1456 || omx.y < result_min.y
1457 || omn.y > result_max.y
1458 || omx.z < result_min.z
1459 || omn.z > result_max.z;
1460 if no_overlap {
1461 continue;
1462 }
1463 let open_vol = (omx.x - omn.x) as f64
1464 * (omx.y - omn.y) as f64
1465 * (omx.z - omn.z) as f64;
1466 if open_vol < Self::min_opening_volume(self.tessellation_quality) {
1467 continue;
1468 }
1469 let depth_dir = extrusion_dir
1470 .filter(|d| d.norm() > NORMALIZE_EPSILON)
1471 .unwrap_or_else(|| opening_mesh_thinnest_axis_dir(opening_mesh));
1472 // Weld (1 µm) to bit-identical so a geometrically-watertight cutter
1473 // whose shared-edge f32 coords differ in bits after the placement
1474 // transform still passes the bit-exact closure gate below and can
1475 // join a batch instead of re-jittering through sequential cuts (#098).
1476 let ext = Self::extend_opening_mesh_through_host(opening_mesh, &result, depth_dir)
1477 .welded_by_position(1.0e-6);
1478 // #2176: only per-component-watertight solids may join a group.
1479 if !mesh_is_closed_exact(&ext) {
1480 continue;
1481 }
1482 let (lo, hi) = ext.bounds();
1483 // Engulf-class exclusion: a cutter whose extended AABB covers
1484 // the whole host on EVERY axis (3% slack, the sequential
1485 // engulf test) stays on the sequential path, where the
1486 // near-engulf and redundant-void guards live — batched it can
1487 // shave the host's outer shell (the #559171-family residual).
1488 let engulfs = {
1489 let tol = ENGULF_TOLERANCE;
1490 let covers = |omin: f64, omax: f64, wmin: f64, wmax: f64| {
1491 let slack = (wmax - wmin).abs().max(1.0e-9) * tol;
1492 omin <= wmin + slack && omax >= wmax - slack
1493 };
1494 covers(lo.x as f64, hi.x as f64, wall_min.x, wall_max.x)
1495 && covers(lo.y as f64, hi.y as f64, wall_min.y, wall_max.y)
1496 && covers(lo.z as f64, hi.z as f64, wall_min.z, wall_max.z)
1497 };
1498 if engulfs {
1499 continue;
1500 }
1501 cands.push(Cand {
1502 idx,
1503 mesh: ext,
1504 lo: [
1505 lo.x as f64 - BATCH_PAD,
1506 lo.y as f64 - BATCH_PAD,
1507 lo.z as f64 - BATCH_PAD,
1508 ],
1509 hi: [
1510 hi.x as f64 + BATCH_PAD,
1511 hi.y as f64 + BATCH_PAD,
1512 hi.z as f64 + BATCH_PAD,
1513 ],
1514 });
1515 }
1516 // Greedy disjoint grouping, deterministic in opening order: a
1517 // candidate joins the first group whose EVERY member's inflated
1518 // AABB is disjoint from its own.
1519 let mut groups: Vec<Vec<usize>> = Vec::new();
1520 'cand: for ci in 0..cands.len() {
1521 for g in groups.iter_mut() {
1522 let disjoint_from_all = g.iter().all(|&cj| {
1523 let (a, b) = (&cands[ci], &cands[cj]);
1524 a.hi[0] < b.lo[0]
1525 || a.lo[0] > b.hi[0]
1526 || a.hi[1] < b.lo[1]
1527 || a.lo[1] > b.hi[1]
1528 || a.hi[2] < b.lo[2]
1529 || a.lo[2] > b.hi[2]
1530 });
1531 if disjoint_from_all {
1532 g.push(ci);
1533 continue 'cand;
1534 }
1535 }
1536 groups.push(vec![ci]);
1537 }
1538 for g in &groups {
1539 if g.len() < 2 {
1540 continue; // singleton: sequential loop handles it (full guards)
1541 }
1542 let gid = batch_groups.len();
1543 for &ci in g {
1544 batch_group_of.insert(cands[ci].idx, gid);
1545 }
1546 batch_groups
1547 .push(g.iter().map(|&ci| (cands[ci].idx, cands[ci].mesh.clone())).collect());
1548 }
1549 }
1550
1551 for (opening_idx, opening) in all_openings.iter().enumerate() {
1552 if batch_consumed[opening_idx] {
1553 continue; // already cut as part of a batched disjoint group
1554 }
1555 // Batched group cut, attempted ONCE, inline at the group's first
1556 // member — so the relative order of batched vs sequential cutters
1557 // matches the pure sequential pass. On any failure (admission,
1558 // guards, or the kernel's conformity gate) the members fall back
1559 // to the per-opening sequential path below.
1560 if let Some(&gid) = batch_group_of.get(&opening_idx) {
1561 let members = std::mem::take(&mut batch_groups[gid]);
1562 if members.len() >= 2 {
1563 // While the host is unmutated, the admission-time extended
1564 // cutters (built against this exact host) are reused as-is;
1565 // after any cut, members are re-extended against the
1566 // CURRENT host — matching the sequential loop's reference,
1567 // which extends each cutter against the (k−1)-cut host.
1568 let mut extended: Vec<(usize, Mesh)> = Vec::with_capacity(members.len());
1569 let mut admissible = true;
1570 if !host_mutated {
1571 extended = members;
1572 } else {
1573 for &(m_idx, _) in &members {
1574 let norm: Option<(&Mesh, Option<Vector3<f64>>)> =
1575 match *all_openings[m_idx] {
1576 OpeningType::Rectangular(..) => None,
1577 OpeningType::DiagonalRectangular(ref m, ref f) => {
1578 Some((m, Some(f.depth)))
1579 }
1580 OpeningType::NonRectangular(ref m, _, _, ref d) => {
1581 Some((m, *d))
1582 }
1583 };
1584 let Some((opening_mesh, extrusion_dir)) = norm else {
1585 admissible = false;
1586 break;
1587 };
1588 let depth_dir = extrusion_dir
1589 .filter(|d| d.norm() > NORMALIZE_EPSILON)
1590 .unwrap_or_else(|| opening_mesh_thinnest_axis_dir(opening_mesh));
1591 let ext = Self::extend_opening_mesh_through_host(
1592 opening_mesh,
1593 &result,
1594 depth_dir,
1595 );
1596 // the re-extended cutter must stay watertight (#2176)
1597 if !mesh_is_closed_exact(&ext) {
1598 admissible = false;
1599 break;
1600 }
1601 extended.push((m_idx, ext));
1602 }
1603 }
1604 if admissible {
1605 let cutters: Vec<&Mesh> = extended.iter().map(|(_, m)| m).collect();
1606 let tri_before = result.triangle_count();
1607 let vol_before = mesh_signed_volume(&result);
1608 if let Ok(csg_result) = clipper.subtract_mesh_many(&result, &cutters) {
1609 let min_tris = (tri_before / CSG_TRIANGLE_RETENTION_DIVISOR)
1610 .max(MIN_VALID_TRIANGLES);
1611 let changed = cut_changed_mesh(&csg_result, tri_before, vol_before);
1612 if !csg_result.is_empty()
1613 && csg_result.triangle_count() >= min_tris
1614 && changed
1615 {
1616 result = csg_result;
1617 host_mutated = true;
1618 for &(m_idx, _) in &extended {
1619 batch_consumed[m_idx] = true;
1620 }
1621 }
1622 }
1623 }
1624 }
1625 if batch_consumed[opening_idx] {
1626 continue; // this opening was cut with its group
1627 }
1628 }
1629 // Normalize both exact-subtract variants into the same (mesh, min,
1630 // max, dir) shape. `DiagonalRectangular` (a clean but tilted box —
1631 // e.g. a roof-slope window or a slanted roof opening, #1007 defect B)
1632 // is a REAL solid cutter, so it MUST be subtracted exactly, never
1633 // approximated by a frame-rotated AABB: that legacy path
1634 // (`apply_diagonal_openings`) tore the host (101 boundary edges) and
1635 // left the void uncut. Route it through the same exact-mesh subtract
1636 // as `NonRectangular`; the kernel cuts the tilted box cleanly
1637 // (winding-robust after the defect-A orient-outward fix). The
1638 // mesh-bounds AABB + frame-depth direction still seed the #635
1639 // fallback, which only fires if the exact subtract no-ops.
1640 let normalized: Option<(&Mesh, Point3<f64>, Point3<f64>, Option<Vector3<f64>>)> =
1641 match *opening {
1642 OpeningType::Rectangular(..) => None,
1643 OpeningType::DiagonalRectangular(ref opening_mesh, ref frame) => {
1644 let (mn, mx) = opening_mesh.bounds();
1645 Some((
1646 opening_mesh,
1647 Point3::new(mn.x as f64, mn.y as f64, mn.z as f64),
1648 Point3::new(mx.x as f64, mx.y as f64, mx.z as f64),
1649 Some(frame.depth),
1650 ))
1651 }
1652 OpeningType::NonRectangular(
1653 ref opening_mesh,
1654 ref open_min_pt,
1655 ref open_max_pt,
1656 ref extrusion_dir,
1657 ) => Some((opening_mesh, *open_min_pt, *open_max_pt, *extrusion_dir)),
1658 };
1659 if let Some((opening_mesh, open_min_pt, open_max_pt, extrusion_dir)) = normalized {
1660 let open_min_pt = &open_min_pt;
1661 let open_max_pt = &open_max_pt;
1662 {
1663 let opening_valid = !opening_mesh.is_empty()
1664 && opening_mesh.positions.iter().all(|&v| v.is_finite())
1665 && opening_mesh.positions.len() >= 9;
1666
1667 if !opening_valid {
1668 continue;
1669 }
1670
1671 let (result_min, result_max) = result.bounds();
1672 let (open_min_f32, open_max_f32) = opening_mesh.bounds();
1673 let no_overlap = open_max_f32.x < result_min.x
1674 || open_min_f32.x > result_max.x
1675 || open_max_f32.y < result_min.y
1676 || open_min_f32.y > result_max.y
1677 || open_max_f32.z < result_min.z
1678 || open_min_f32.z > result_max.z;
1679 if no_overlap {
1680 continue;
1681 }
1682
1683 let open_vol = (open_max_f32.x - open_min_f32.x)
1684 * (open_max_f32.y - open_min_f32.y)
1685 * (open_max_f32.z - open_min_f32.z);
1686 // The 0.1 L volume floor filtered legacy-BSP CSG artefacts but
1687 // also drops genuine small openings — bolt holes / sleeves in
1688 // thin plates. At the two highest quality levels keep those
1689 // holes (the exact kernel is stable on small cutters); only
1690 // reject numerically degenerate cutters there. (issue #976)
1691 let min_open_vol = Self::min_opening_volume(self.tessellation_quality) as f32;
1692 if open_vol < min_open_vol {
1693 continue;
1694 }
1695
1696 // ENGULFING-SOLID VOID: when this opening's real solid
1697 // CONTAINS the whole host, the exact subtract no-ops on the
1698 // coincident shared faces and returns the host unchanged in
1699 // volume (a spurious solid where the opening should be). A
1700 // cheap AABB-engulf pre-check (false for an ordinary opening,
1701 // which spans the host on at most its thickness axis) gates
1702 // the O(host_v · opening_t) containment scan, which confirms
1703 // TRUE solid containment — so a void whose AABB engulfs the
1704 // host while its real profile excludes it is not affected.
1705 // On a hit the host is fully consumed.
1706 let aabb_engulfs = {
1707 let tol = ENGULF_TOLERANCE_F32;
1708 let covers = |omin: f32, omax: f32, hmin: f32, hmax: f32| {
1709 let slack = (hmax - hmin).abs().max(1.0e-9) * tol;
1710 omin <= hmin + slack && omax >= hmax - slack
1711 };
1712 covers(open_min_f32.x, open_max_f32.x, result_min.x, result_max.x)
1713 && covers(open_min_f32.y, open_max_f32.y, result_min.y, result_max.y)
1714 && covers(open_min_f32.z, open_max_f32.z, result_min.z, result_max.z)
1715 };
1716 if aabb_engulfs && opening_engulfs_host_solid(&result, opening_mesh) {
1717 // Mark the host consumed so the element pipeline keeps
1718 // the empty result instead of falling back to the un-cut
1719 // host.
1720 self.record_void_consumed_host(element_id);
1721 result = Mesh::default();
1722 host_mutated = true;
1723 break;
1724 }
1725
1726 let tri_before = result.triangle_count();
1727 let failures_before = clipper.failure_count();
1728 let mut csg_succeeded = false;
1729 // Tracks whether CSG returned the host *unchanged* (the kernel
1730 // either found no real intersection, or errored on a grazing/
1731 // coplanar cutter and returned the un-cut host).
1732 let mut csg_unchanged = false;
1733 // PENETRATING CUTTER (PART A): push the opening's caps a hair
1734 // PAST the host along its depth axis so a flush cap becomes a
1735 // clean transversal crossing — the exact kernel then cuts the
1736 // tilted, faceted roof opening with no bridging sliver (#1007
1737 // host #1112). Falls back to the raw opening mesh when no depth
1738 // direction is known (the kernel handles a true through-cutter
1739 // anyway; the extension only matters for the flush-cap case).
1740 let depth_dir = extrusion_dir
1741 .filter(|d| d.norm() > NORMALIZE_EPSILON)
1742 .unwrap_or_else(|| opening_mesh_thinnest_axis_dir(opening_mesh));
1743 let extended_opening = Self::extend_opening_mesh_through_host(
1744 opening_mesh,
1745 &result,
1746 depth_dir,
1747 );
1748 let cutter = &extended_opening;
1749 let vol_before = mesh_signed_volume(&result);
1750 if let Ok(csg_result) = clipper.subtract_mesh(&result, cutter) {
1751 let min_tris = (tri_before / CSG_TRIANGLE_RETENTION_DIVISOR)
1752 .max(MIN_VALID_TRIANGLES);
1753 let changed = cut_changed_mesh(&csg_result, tri_before, vol_before);
1754 csg_unchanged = !changed;
1755 if !csg_result.is_empty()
1756 && csg_result.triangle_count() >= min_tris
1757 && changed
1758 {
1759 result = csg_result;
1760 host_mutated = true;
1761 csg_succeeded = true;
1762 }
1763 }
1764
1765 // AABB fallback (issue #635): when CSG can't subtract the
1766 // opening (most commonly because its triangulated profile
1767 // exceeds `MAX_CSG_POLYGONS_PER_MESH`, i.e. circular /
1768 // arched / arbitrary curved openings), cut the opening's
1769 // axis-aligned bounding box instead. This leaves a square
1770 // hole in place of a round one, but a square hole is
1771 // dramatically less wrong than a missing void on a wall
1772 // that is supposed to host a window or door.
1773 if !csg_succeeded {
1774 let dir = extrusion_dir.or_else(|| {
1775 Some(wall_thinnest_axis_dir(&wall_min, &wall_max))
1776 });
1777 let (final_min, final_max) = if let Some(dir) = dir {
1778 self.extend_opening_along_direction(
1779 *open_min_pt,
1780 *open_max_pt,
1781 wall_min,
1782 wall_max,
1783 dir,
1784 )
1785 } else {
1786 (*open_min_pt, *open_max_pt)
1787 };
1788 // Near-engulf guard. When CSG returned the host *unchanged*
1789 // (no cut) AND the opening's AABB covers the whole wall on
1790 // every axis, the rectangular fallback would cut that
1791 // engulfing box and delete the wall. This is the signature
1792 // of a non-rectangular opening whose bounding box engulfs
1793 // the host while its real profile excludes it: the kernel
1794 // errors on the grazing/coplanar cutter and returns the
1795 // un-cut host, which is already the correct result vs
1796 // IfcOpenShell (advanced #555433's facade-scale void
1797 // #555493). Keep the un-cut host instead of over-cutting.
1798 // Normal windows/doors — including the issue-635 high-poly
1799 // round openings the AABB box approximates — sit INSIDE the
1800 // wall and never engulf it, so they still take the fallback.
1801 // The 3% per-axis tolerance absorbs an opening that reaches
1802 // ~flush with a wall face (its near plane).
1803 let engulfs_host = {
1804 let tol = ENGULF_TOLERANCE;
1805 let covers = |omin: f64, omax: f64, wmin: f64, wmax: f64| {
1806 let slack = (wmax - wmin).abs().max(1.0e-9) * tol;
1807 omin <= wmin + slack && omax >= wmax - slack
1808 };
1809 covers(final_min.x, final_max.x, wall_min.x, wall_max.x)
1810 && covers(final_min.y, final_max.y, wall_min.y, wall_max.y)
1811 && covers(final_min.z, final_max.z, wall_min.z, wall_max.z)
1812 };
1813 // `capped` keys on the `OperandTooLarge` rejection: the
1814 // #1109 per-boolean/-element escalation budget records it
1815 // when a cut trips mid-arrangement and bails to the un-cut
1816 // host (`csg/mod.rs`), so it is NOT always false — a
1817 // grazing engulfing cutter that would run long trips the
1818 // budget just as it errors on the coincident faces.
1819 // Engulf suppression therefore applies regardless of WHY
1820 // the CSG produced no change (budget trip or kernel error
1821 // on the coincident faces): an engulfing cutter's AABB
1822 // covers the whole host, so the #635 box-cut would delete
1823 // the entire wall — a silent element deletion strictly
1824 // worse than leaving a phantom (un-cut) solid. `capped` is
1825 // now only read by the diagnostic below (it no longer
1826 // gates the suppression), so it is unused in a release
1827 // build that compiles out both the tracing and the
1828 // debug/test `eprintln!` legs of `diag_warn!`.
1829 #[allow(unused_variables)]
1830 let capped = clipper.has_operand_too_large_since(failures_before);
1831 // Issue #964: suppress the destructive AABB box when the
1832 // host already has this void cut into it (a void
1833 // double-encoded as both a profile inner curve and a
1834 // redundant IfcOpeningElement). When every column through
1835 // the opening footprint is already open in the host,
1836 // cutting the bounding box would replace a correct
1837 // round/polygonal hole with a rectangle. Unlike the
1838 // engulf heuristic this is a positive void detection, so
1839 // it overrides `capped` too (the void demonstrably
1840 // exists — there is nothing left to approximate).
1841 let probe_axis = dir.unwrap_or_else(|| {
1842 wall_thinnest_axis_dir(&wall_min, &wall_max)
1843 });
1844 let redundant_void =
1845 opening_redundant_with_host(&result, opening_mesh, &probe_axis);
1846 let suppress_fallback =
1847 redundant_void || (csg_unchanged && engulfs_host);
1848 if !suppress_fallback {
1849 // Diagnostic for issue #635: log the opening
1850 // triangle count when the AABB fallback actually
1851 // fires, so round windows (post profile
1852 // simplification) can be confirmed to hit CSG and
1853 // only genuinely-uncut voids land on the box cut.
1854 // A genuine degraded mode, so warn-level under
1855 // observability; the legacy debug/test-only
1856 // eprintln is preserved otherwise.
1857 crate::diag::diag_warn!(
1858 { opening_tris = opening_mesh.triangle_count(),
1859 reason = "csg-produced-no-change",
1860 "voids: AABB fallback cut used for opening" }
1861 else {
1862 #[cfg(any(debug_assertions, test))]
1863 eprintln!(
1864 "[issue-635] AABB fallback used: opening={} tris (CSG produced no change)",
1865 opening_mesh.triangle_count()
1866 );
1867 }
1868 );
1869 // Deliberate degraded mode: this fallback removes
1870 // the wall material inside the opening AABB but no
1871 // longer emits reveal/recess quads (deleted with
1872 // the legacy clip path), so its output has an open
1873 // rim. Acceptable for a safety net that fired 0x
1874 // across the regression corpus — the exact-kernel
1875 // path ahead of it emits the reveals itself.
1876 let aabb_cut =
1877 self.cut_rectangular_opening(&result, final_min, final_max);
1878 if !aabb_cut.is_empty() && aabb_cut.triangle_count() != tri_before {
1879 result = aabb_cut;
1880 host_mutated = true;
1881 }
1882 } else {
1883 // The engulfing/redundant-void guard suppressed the
1884 // #635 AABB box-cut. For an engulfing cutter the box
1885 // covers the whole host, so cutting it would DELETE
1886 // the wall; leaving the host un-cut (a phantom solid)
1887 // is the strictly safer degrade. `capped` records
1888 // whether the CSG bailed via the #1109 budget trip
1889 // (`OperandTooLarge`) vs a kernel error on the
1890 // coincident faces.
1891 crate::diag::diag_warn!(
1892 { opening_tris = opening_mesh.triangle_count(),
1893 capped = capped,
1894 reason = "engulfing-cutter-fallback-suppressed",
1895 "voids: AABB fallback suppressed for engulfing cutter (host left un-cut)" }
1896 else {
1897 #[cfg(any(debug_assertions, test))]
1898 eprintln!(
1899 "[issue-635] AABB fallback SUPPRESSED for engulfing cutter: opening={} tris, capped={} (host left un-cut)",
1900 opening_mesh.triangle_count(),
1901 capped
1902 );
1903 }
1904 );
1905 }
1906 }
1907 }
1908 }
1909 }
1910
1911 // NOTE (issue #635): the clipping planes from `IfcBooleanClippingResult`
1912 // are already applied by `BooleanClippingProcessor::process` during
1913 // `process_element` — the post-clip mesh is the *input* to this
1914 // function. Re-clipping here was a leftover from before that
1915 // processor existed; for `IfcPolygonalBoundedHalfSpace` it actively
1916 // *broke* gable walls, because `extract_half_space_plane` discards
1917 // the polygonal bound and the resulting unbounded plane chops off
1918 // the gable peak. Voids alone are applied here.
1919
1920 // Drain whatever fallbacks the kernel logged during this element's
1921 // void / clip pass, attribute them to the host product, and stash on
1922 // the router so the caller can surface them (e.g. flagged in a
1923 // viewer overlay or asserted in regression tests).
1924 let kernel_failures = clipper.take_failures();
1925 if !kernel_failures.is_empty() {
1926 self.record_host_failure_summary(element_id, &kernel_failures);
1927 self.record_csg_failures(element_id, kernel_failures);
1928 }
1929
1930 // WATERTIGHT SLIVER REFINEMENT (issue #1007): the exact-kernel cut of a
1931 // long, tilted faceted-BREP host facet can emit a high-aspect corner
1932 // sliver (a far-corner triangle fanned to two new rim vertices a few cm
1933 // apart) that lands ALONE in its plane bucket and so bypasses the
1934 // coplanar CDT. Bisect any >8:1 triangle's longest edge at its midpoint,
1935 // splitting BOTH incident triangles in lockstep so the mesh stays
1936 // watertight (no T-junction) and the midpoint lies ON the original edge
1937 // ⇒ cut volume is preserved exactly. A no-op on clean cuts (no triangle
1938 // exceeds 8:1), so it does not perturb the frozen corpus. Only runs when
1939 // a cut was actually attempted (`!ctx.is_noop()` guarantees this path).
1940 let mut result = crate::facet_weld::refine_high_aspect_slivers(&result);
1941
1942 // UNDER-CUT REPAIR: a self-intersecting tessellated cutter (garbage
1943 // vertices metres from the real opening) makes the kernel UNDER-cut — a
1944 // wall flap bridges the opening. Re-cut each such opening with a clean
1945 // box (removes only the opening prism, taking the flap, while preserving
1946 // and re-triangulating the wall around the hole). Done HERE, in the cut
1947 // frame (host + cutters share it), and BEFORE the spike clip so any
1948 // residual protrusion is still caught. A no-op when no cutter is
1949 // malformed — clean openings are never reshaped.
1950 recut_malformed_openings(&mut result, &ctx.malformed_opening_boxes());
1951
1952 // SPURIOUS-FLAP CLIP: a subtract can only remove material, so the cut is
1953 // mathematically contained in the host's pre-cut AABB (`wall_min/max`);
1954 // any triangle poking past it is provably an artifact — a malformed
1955 // cutter's far-flung leaked flap (self-intersecting / tessellated void,
1956 // surfacing once a SECOND cutter perturbs the arrangement), OR the
1957 // flush-cap through-extension reveal overhang (`extend_opening_mesh_
1958 // through_host` pushes a flush cap ~0.3·depth past the host for a clean
1959 // transversal cut, so the subtract emits the reveal out to it — 0.105 m
1960 // past a 0.35 m floor slab, #1633). `clip_triangles_to_host_aabb` bounds
1961 // its jitter tolerance so a large host cannot leak the overhang. A no-op
1962 // on clean cuts.
1963 result.clip_triangles_to_host_aabb(
1964 [wall_min.x as f32, wall_min.y as f32, wall_min.z as f32],
1965 [wall_max.x as f32, wall_max.y as f32, wall_max.z as f32],
1966 );
1967
1968 // STRAY-SHARD SWEEP: see `sweep::drop_faces_outside_host` (#1788).
1969 if host_mutated {
1970 result = drop_faces_outside_host(result, &original_host);
1971 }
1972
1973 // Per-host cut-effect snapshot: tris_before / tris_after lets the
1974 // diagnostic surface the silent-no-op case (rectangular boxes
1975 // processed but the host mesh came out unchanged — the box
1976 // probably didn't intersect the wall, e.g. wrong placement).
1977 self.record_host_cut_effect(
1978 element_id,
1979 tris_before,
1980 result.triangle_count(),
1981 synth_rect.len(),
1982 host_bounds_capture,
1983 );
1984
1985 result
1986 }
1987
1988 /// Process an element into per-item sub-meshes with opening subtraction.
1989 ///
1990 /// Mirrors [`process_element_with_voids`] but preserves each
1991 /// `IfcShapeRepresentation` item as its own sub-mesh so that callers can
1992 /// look up a direct `IfcStyledItem` color per geometry item (e.g. the
1993 /// three extrusion layers of a multi-layer wall). The opening(s) are
1994 /// subtracted from each sub-mesh independently so that windows and doors
1995 /// cut through every material layer they intersect.
1996 ///
1997 /// Returns an empty collection when there are no openings (callers should
1998 /// fall back to [`process_element_with_submeshes`]) or when every
1999 /// sub-mesh is destroyed by void subtraction.
2000 pub fn process_element_with_submeshes_and_voids(
2001 &self,
2002 element: &DecodedEntity,
2003 decoder: &mut EntityDecoder,
2004 void_index: &FxHashMap<u32, Vec<u32>>,
2005 ) -> Result<SubMeshCollection> {
2006 // Layered single-solid path: slice the element's base mesh by its
2007 // material-layer buildup AFTER subtracting voids. This produces one
2008 // sub-mesh per layer keyed by IfcMaterial id, so layers show up as
2009 // individual colors even when the underlying geometry is a single
2010 // swept solid.
2011 if let Some(layered) = self.try_layered_sub_meshes(element, decoder, Some(void_index)) {
2012 return Ok(layered);
2013 }
2014
2015 let opening_ids = match void_index.get(&element.id) {
2016 Some(ids) if !ids.is_empty() => ids.clone(),
2017 _ => return Ok(SubMeshCollection::new()),
2018 };
2019
2020 // Voided occurrences materialize cut geometry (#1623 don't-bake off) with no
2021 // texture index (#1781: CSG rebuilds vertices, orphaning UVs — colour wins).
2022 let sub_meshes = self.process_element_with_submeshes_impl(element, decoder, false, None)?;
2023 if sub_meshes.is_empty() {
2024 return Ok(SubMeshCollection::new());
2025 }
2026
2027 // Classify openings + resolve clipping planes ONCE per element. Doing
2028 // this per sub-mesh would re-run `process_element` on every opening
2029 // and re-extract clipping planes N times, multiplying the expensive
2030 // parsing/CSG setup by the sub-mesh count on the exact elements this
2031 // path targets (multi-layer walls with windows).
2032 let ctx = self.build_void_context(element, &opening_ids, decoder);
2033
2034 let mut voided = SubMeshCollection::new();
2035 for sub in sub_meshes.sub_meshes {
2036 let geometry_id = sub.geometry_id;
2037 let mut voided_mesh = self.apply_void_context(sub.mesh, &ctx, element.id);
2038 // Same CSG-seam hygiene as the single-mesh void path.
2039 voided_mesh.clean_degenerate();
2040 if !voided_mesh.is_empty() {
2041 voided
2042 .sub_meshes
2043 .push(SubMesh::new(geometry_id, voided_mesh));
2044 }
2045 }
2046
2047 Ok(voided)
2048 }
2049}
2050
2051#[cfg(test)]
2052mod flap_clip_tests;