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;