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