Skip to main content

ifc_lite_geometry/router/voids/
mod.rs

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