ifc_lite_geometry/router/voids/
bool2d_path.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//! 2D opening-subtraction fast path (IfcOpenShell's `boolean-attempt-2d`).
6//! For the common case — an extruded host whose openings penetrate straight
7//! through the extrusion depth — the exact 3D mesh-boolean is replaced by a much
8//! cheaper operation: subtract the openings' footprints from the host's 2D
9//! profile ([`crate::bool2d::subtract_multiple_2d`], i_overlay), then re-extrude
10//! the holed profile. On CSG-heavy models the void-cut is ~80% of geometry time
11//! and the exact kernel is at its single-threaded, bandwidth-bound floor; this
12//! path collapses each qualifying host to a couple of profile triangulations.
13//! HYBRID eligibility subtracts parallel through-cuts in 2D. Perpendicular,
14//! partial-depth, and non-extruded openings remain residual 3D cuts on the
15//! re-extruded host, so one ineligible opening does not forfeit the cheap ones.
16//! Pure 2D cuts union overlapping footprints. Mixed cuts stage a stable precursor,
17//! residual cuts, and mandatory overlap corrections; only their complete union
18//! difference may be returned. This avoids changing the residual cutter's input
19//! tessellation when overlapping footprints merge (#4617).
20//! Safety gates validate the 2D PREFIX before residual routing:
21//!   1. Host body = ONE `IfcExtrudedAreaSolid` swept along local ±Z (arbitrary
22//!      profile OK; mapped items unwrapped; a clipped / multi-item body defers).
23//!   2. Each eligible footprint lands STRICTLY INTERIOR to the host profile
24//!      (gated at capture). A footprint touching or breaching the boundary is
25//!      routed to the residual instead.
26//!   3. The difference must yield ONE connected shape (a void that splits the
27//!      profile is rejected — the largest-shape keep would drop geometry) with at
28//!      least one hole formed.
29//!   4. The no-hole re-extrude reconciles with the host mesh (bounds + volume),
30//!      and the holed re-extrude self-checks watertight. The later residual result
31//!      is not certified by this gate; #4610 pins its established census reading.
32//!
33//! A prefix-gate failure falls through with the full opening set unchanged.
34//!
35//! Gate: `IFC_LITE_VOID_2D=0` forces every host back through the exact kernel
36//! (A/B measurement / bisection). Default ON. wasm has no env, so the default
37//! holds on both targets and native==wasm output stays deterministic (all-f64
38//! before the f32 store).
39
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::sync::OnceLock;
42
43use super::geom::{mesh_signed_volume, param_cut_watertight};
44use super::{world_host_bounds, GeometryRouter, VoidContext};
45use crate::bool2d::{subtract_multiple_2d_counted, subtract_staged_2d_counted};
46use crate::extrusion::{apply_transform, extrude_profile, extrude_profile_watertight};
47use crate::mesh::Mesh;
48use crate::profile::Profile2D;
49use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
50use nalgebra::{Matrix4, Point2, Vector3};
51
52mod footprint;
53use footprint::{footprint_interior, opening_solid_footprint};
54
55/// `IFC_LITE_VOID_2D=0` disables the 2D opening-subtraction path (exact kernel
56/// for every host). Default ON; read once.
57pub(super) fn enabled() -> bool {
58    static ON: OnceLock<bool> = OnceLock::new();
59    *ON.get_or_init(|| std::env::var("IFC_LITE_VOID_2D").as_deref() != Ok("0"))
60}
61
62static FIRES: AtomicU64 = AtomicU64::new(0);
63static FOOTPRINTS: AtomicU64 = AtomicU64::new(0);
64
65/// Read + reset the 2D-path telemetry: (hosts cut via the 2D path, opening
66/// footprints subtracted in those hosts). Process-global; used by the perf
67/// harness to report the fast-path hit-rate. Relaxed atomics — a stale read
68/// under concurrency only mis-reports a diagnostic count, never geometry.
69pub fn take_bool2d_stats() -> (u64, u64) {
70    (
71        FIRES.swap(0, Ordering::Relaxed),
72        FOOTPRINTS.swap(0, Ordering::Relaxed),
73    )
74}
75
76/// Captured 2D-subtraction data for a host + its openings, sufficient to build
77/// the cut mesh WITHOUT the decoder (so [`GeometryRouter::apply_void_context`]
78/// can run it on pure geometry). Built in [`GeometryRouter::capture_bool2d`].
79pub(super) struct Bool2dCut {
80    /// Host profile (outer + any existing holes) in the solid's profile plane.
81    host_profile: Profile2D,
82    /// Host extrusion depth (> 0).
83    depth: f64,
84    /// +1 / -1 host sweep sense (drives the extrude cap placement).
85    dir_sign: f64,
86    /// Profile-local → world (metres, RTC-relative) transform: `scale · m − rtc`.
87    wt: Matrix4<f64>,
88    /// Eligible (parallel through-cut) opening footprints in the host profile
89    /// plane (host XY).
90    footprints: Vec<Vec<Point2<f64>>>,
91    /// Ineligible openings (perpendicular / partial / non-extruded) as a residual
92    /// exact-kernel context, cut on the re-extruded host after the 2D subtract.
93    /// `None` when every opening was eligible (pure 2D result).
94    residual: Option<Box<VoidContext>>,
95}
96
97impl GeometryRouter {
98    /// Build the 2D-subtraction data for a host + its openings. Splits the
99    /// openings into eligible (parallel through-cut → 2D) and residual
100    /// (everything else → exact kernel). Returns `None` if the host is not a
101    /// single clean extrusion or no opening is eligible (nothing to gain).
102    pub(super) fn capture_bool2d(
103        &self,
104        element: &DecodedEntity,
105        opening_ids: &[u32],
106        decoder: &mut EntityDecoder,
107    ) -> Option<Bool2dCut> {
108        if opening_ids.is_empty() {
109            return None;
110        }
111        let host = self.host_extruded_solid(element, decoder)?;
112        let hm_inv = host.m.try_inverse()?;
113        let host_rot = host.m.fixed_view::<3, 3>(0, 0).into_owned();
114        let host_axis = (host_rot * Vector3::new(0.0, 0.0, host.dir_sign)).try_normalize(1e-9)?;
115        // Host extrusion span in the solid's profile frame.
116        let (hz_min, hz_max) = if host.dir_sign >= 0.0 {
117            (0.0, host.depth)
118        } else {
119            (-host.depth, 0.0)
120        };
121        // Numerical-precision coincidence (1 ppm): near-through blind openings
122        // defer to the exact kernel instead of becoming a full-depth hole.
123        let z_tol = 1.0e-6 * host.depth.abs() + 1.0e-9;
124
125        let mut footprints: Vec<Vec<Point2<f64>>> = Vec::new();
126        let mut residual_ids: Vec<u32> = Vec::new();
127        for &oid in opening_ids {
128            let opening = match decoder.decode_by_id(oid) {
129                Ok(e) if e.ifc_type == IfcType::IfcOpeningElement => e,
130                // A non-opening (or undecodable) id: hand it to the exact kernel.
131                _ => {
132                    residual_ids.push(oid);
133                    continue;
134                }
135            };
136            // An opening is eligible only when EVERY constituent solid is a
137            // parallel through-cut whose hole-free footprint lands STRICTLY
138            // INTERIOR to the host profile. A footprint that touches / breaches
139            // the boundary, or lands in an existing profile hole, is routed to
140            // the residual so it never silently no-ops. Collect tentatively; any
141            // ineligible solid sends the
142            // whole opening (all its solids) to the residual so nothing is
143            // double-cut.
144            let mut opening_footprints: Vec<Vec<Point2<f64>>> = Vec::new();
145            let eligible = self
146                .opening_extruded_solids(&opening, decoder)
147                .map(|solids| {
148                    solids.iter().all(|op| {
149                        match opening_solid_footprint(
150                            op, &hm_inv, &host_axis, hz_min, hz_max, z_tol,
151                        ) {
152                            Some(fp) if footprint_interior(&fp, &host.profile) => {
153                                opening_footprints.push(fp);
154                                true
155                            }
156                            _ => false,
157                        }
158                    })
159                })
160                .unwrap_or(false);
161            if eligible && !opening_footprints.is_empty() {
162                footprints.append(&mut opening_footprints);
163            } else {
164                residual_ids.push(oid);
165            }
166        }
167        if footprints.is_empty() {
168            return None;
169        }
170
171        // Profile-local → world metres, RTC-relative: world = scale·(m·p) − rtc.
172        // Scaling rows 0..3 (incl. their translation column) then subtracting the
173        // RTC offset reproduces `center_native · s − rtc` from the rect probe.
174        let s = self.unit_scale;
175        let (rx, ry, rz) = self.rtc_offset;
176        let mut wt = host.m;
177        for i in 0..3 {
178            for j in 0..4 {
179                wt[(i, j)] *= s;
180            }
181        }
182        wt[(0, 3)] -= rx;
183        wt[(1, 3)] -= ry;
184        wt[(2, 3)] -= rz;
185
186        // Residual exact-kernel context for the ineligible openings (diagnostics
187        // suppressed — the full opening set is already diagnosed by
188        // `build_void_context`'s own `classify_openings`).
189        let residual = if residual_ids.is_empty() {
190            None
191        } else {
192            let openings = self.classify_openings_quiet(element, &residual_ids, decoder);
193            if openings.is_empty() {
194                None
195            } else {
196                let merged = Self::merge_rectangular_openings(&openings);
197                Some(Box::new(VoidContext {
198                    openings,
199                    merged_openings: merged,
200                    param: None,
201                    bool2d: None,
202                }))
203            }
204        };
205
206        Some(Bool2dCut {
207            host_profile: host.profile,
208            depth: host.depth,
209            dir_sign: host.dir_sign,
210            wt,
211            footprints,
212            residual,
213        })
214    }
215
216    /// Emit the staged re-extruded precursor and mandatory correction cutters.
217    /// The caller must cut residual openings, then remove all corrections before
218    /// returning a mixed result. Returns `None` (→ full exact kernel) if
219    /// reconciliation or the watertight self-check fails. Deterministic f64 →
220    /// byte-identical native==wasm.
221    pub(super) fn try_bool2d_cut(&self, mesh: &Mesh, cut: &Bool2dCut) -> Option<(Mesh, Vec<Mesh>)> {
222        // A -Z sweep places the profile plane at the TOP; shift the [0, depth]
223        // extrude down so the solid occupies [-depth, 0] in the profile frame,
224        // matching the extruded-solid processor's downward-extrusion handling.
225        let transform = if cut.dir_sign >= 0.0 {
226            None
227        } else {
228            Some(Matrix4::new_translation(&Vector3::new(
229                0.0, 0.0, -cut.depth,
230            )))
231        };
232
233        // (1) No-hole solid: reconcile transform + profile recovery against the
234        // real (pre-cut) host mesh. A clipped / mis-recovered host mismatches here
235        // and defers, so the emitted geometry can only ever be a true extrusion.
236        let mut solid = extrude_profile(&cut.host_profile, cut.depth, transform).ok()?;
237        apply_transform(&mut solid, &cut.wt);
238        if !reconcile_solid(mesh, &solid) {
239            return None;
240        }
241
242        // (2) 2D difference. The one pathology interiority can't rule out is a
243        // void that SPLITS the profile into
244        // disconnected pieces (an interior slot bridging the outer to an existing
245        // hole): the difference then yields multiple shapes and only the largest
246        // is kept, silently dropping geometry. Reject any multi-shape result and
247        // require at least one hole to have formed (a zero-hole result would signal
248        // a projection error rather than a real cut).
249        let (holed, n_shapes, corrections) = if cut.residual.is_some() {
250            subtract_staged_2d_counted(&cut.host_profile, &cut.footprints).ok()?
251        } else {
252            let (profile, count) = subtract_multiple_2d_counted(&cut.host_profile, &cut.footprints).ok()?;
253            (profile, count, Vec::new())
254        };
255        if n_shapes != 1 {
256            return None;
257        }
258        if holed.holes.len() < cut.host_profile.holes.len() + 1 {
259            return None;
260        }
261
262        // (3) Re-extrude the holed profile (watertight CDT caps) into the host's
263        // OWN frame: fold the host mesh's per-element origin into the transform so
264        // the result carries the SAME `origin` (world = origin + position). On the
265        // native/world default `mesh.origin` is 0 and this is the absolute world
266        // build; under the wasm local-frame default it keeps the cut near its own
267        // origin (f32-precise for a georeferenced host — the #1297 precondition)
268        // instead of collapsing to coarse absolute-world coordinates. Hygiene +
269        // the watertight self-check run in this frame (small coords, precise).
270        let origin = mesh.origin;
271        let mut wt = cut.wt;
272        wt[(0, 3)] -= origin[0];
273        wt[(1, 3)] -= origin[1];
274        wt[(2, 3)] -= origin[2];
275        let mut out = extrude_profile_watertight(&holed, cut.depth, transform).ok()?;
276        apply_transform(&mut out, &wt);
277        out.origin = origin;
278        out.clean_degenerate();
279        if !param_cut_watertight(&out) {
280            return None;
281        }
282
283        let corrections = corrections
284            .iter()
285            .map(|profile| {
286                if !profile.holes.is_empty() {
287                    return None;
288                }
289                let mut correction = extrude_profile_watertight(profile, cut.depth, transform).ok()?;
290                apply_transform(&mut correction, &wt);
291                correction.origin = origin;
292                Some(correction)
293            })
294            .collect::<Option<Vec<_>>>()?;
295        let footprints = cut.footprints.len() as u64;
296        crate::telemetry_transaction::record(move || {
297            FIRES.fetch_add(1, Ordering::Relaxed);
298            FOOTPRINTS.fetch_add(footprints, Ordering::Relaxed);
299        });
300        Some((out, corrections))
301    }
302
303    /// The captured residual (ineligible-opening) exact-kernel context, if any.
304    pub(super) fn bool2d_residual<'a>(&self, cut: &'a Bool2dCut) -> Option<&'a VoidContext> {
305        cut.residual.as_deref()
306    }
307}
308
309/// Reconcile the no-hole re-extruded `solid` against the real host `mesh` by
310/// world AABB (per axis) and signed volume. Validates that the recovered profile
311/// and composed transform reproduce the host — catching clipped hosts, unit / RTC
312/// mistakes, and profiles the processor tessellates differently.
313fn reconcile_solid(host: &Mesh, solid: &Mesh) -> bool {
314    let (hmn, hmx) = world_host_bounds(host); // folds host.origin
315    let (smn, smx) = solid.bounds(); // solid is absolute (origin 0)
316    let hmn = [hmn.0, hmn.1, hmn.2];
317    let hmx = [hmx.0, hmx.1, hmx.2];
318    let smn = [smn.x, smn.y, smn.z];
319    let smx = [smx.x, smx.y, smx.z];
320    for k in 0..3 {
321        let ext = (hmx[k] - hmn[k]).abs().max(1e-3);
322        let tol = ext * 0.02 + 5e-3; // 2% of extent + 5 mm
323        if (hmn[k] - smn[k]).abs() > tol || (hmx[k] - smx[k]).abs() > tol {
324            return false;
325        }
326    }
327    let hv = mesh_signed_volume(host).abs();
328    let sv = mesh_signed_volume(solid).abs();
329    if hv < 1e-9 || sv < 1e-9 {
330        return false;
331    }
332    let r = sv / hv;
333    (0.97..1.03).contains(&r)
334}
335
336#[cfg(test)]
337#[path = "bool2d_path_tests.rs"]
338mod tests;
339
340#[cfg(test)]
341#[path = "bool2d_transaction_tests.rs"]
342mod transaction_tests;