ifc_lite_geometry/rect_fast.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//! Analytic fast path for axis-aligned rectangular openings cut through an
6//! axis-aligned box host (the dominant case: rectangular windows/doors in a
7//! straight wall). This sidesteps the exact mesh-arrangement CSG kernel — which
8//! is at its single-threaded, memory-bandwidth-bound floor — for openings that
9//! need no exact arithmetic at all.
10//!
11//! WATERTIGHTNESS RECIPE (the make-or-break the prior `subtract_box_fast`
12//! attempt got wrong by recomputing rim coords on an incommensurate grid):
13//! - Build the cut on ONE canonical grid whose lines are the host edges plus
14//! every opening edge, each value SNAPPED to the kernel's own power-of-two
15//! `SNAP_GRID = 1/65536` (the grid the host operand already lives on).
16//! - CONFORMING-split every face by the crossing grid lines (no T-junctions):
17//! the front/back faces become a grid of cells with hole cells omitted; the
18//! side faces are split at the hole grid lines so their shared edge with the
19//! annulus matches sub-edge for sub-edge.
20//! - Every vertex reads its position from the SAME snapped grid value, so two
21//! faces meeting at a shared line emit BIT-IDENTICAL f32 → watertight by
22//! construction (value identity, not a numeric hash-match).
23//! - Per-face flat normals; vertices are NEVER welded across creases (#846).
24//!
25//! GATING: this is a PURE OPTIMIZATION. It returns `None` (→ caller falls back to
26//! the exact kernel) the moment any precondition fails — non-box host, non-
27//! through opening, opening off the face, or a NEAR-EDGE feature whose grid lines
28//! would collapse into each other at the host's f32 magnitude (the robustness
29//! gate that replaces a hard dependency on the per-element local frame).
30
31use crate::mesh::Mesh;
32
33/// The kernel's canonical reconcile grid. Power of two ⇒ the snap is an exact
34/// f64 op ⇒ bit-deterministic native==wasm.
35use crate::kernel::mesh_bridge::SNAP_GRID;
36
37#[inline]
38fn snap(c: f64) -> f64 {
39 (c / SNAP_GRID).round() * SNAP_GRID
40}
41
42/// Telemetry: why the fast path fired or deferred, so the real fire-rate on a
43/// void-heavy model is measurable (the prior attempt shipped at fired=0).
44#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
45pub struct RectFastStats {
46 pub fired: u64,
47 pub openings_cut: u64,
48 pub defer_host_not_box: u64,
49 pub defer_not_through: u64,
50 pub defer_off_face: u64,
51 pub defer_near_edge: u64,
52 pub defer_no_openings: u64,
53 pub defer_too_many_openings: u64,
54}
55
56/// Escape hatch: `IFC_LITE_RECT_FAST=0` is the GLOBAL rect-fast kill switch.
57/// It forces every opening back through the exact kernel (parity debugging /
58/// bisection) by disabling BOTH the legacy world-axis-aligned path gated here
59/// AND the parametric placement-frame path ([`param_enabled`] honours it), so
60/// setting this single flag is sufficient to route all rectangular openings
61/// through CSG. Default ON: the path is a pure optimization that defers on
62/// any precondition miss. To disable only the parametric path, set
63/// `IFC_LITE_RECT_PARAM=0` instead.
64pub fn enabled() -> bool {
65 use std::sync::OnceLock;
66 static ON: OnceLock<bool> = OnceLock::new();
67 *ON.get_or_init(|| std::env::var("IFC_LITE_RECT_FAST").as_deref() != Ok("0"))
68}
69
70static PARAM_OVERRIDE: std::sync::atomic::AtomicI8 = std::sync::atomic::AtomicI8::new(-1);
71
72/// PARAMETRIC rectangular-opening fast path (placement-frame, ground-truth-exact cut).
73/// DEFAULT ON -- opt out with `IFC_LITE_RECT_PARAM=0` (parametric path only),
74/// `IFC_LITE_RECT_FAST=0` (the GLOBAL rect-fast kill switch: disables this path AND
75/// the legacy [`enabled`] path, preserving the documented single-flag parity/bisection
76/// hatch), or `param_set_enabled_override`. Gated separately from [`enabled`] because
77/// it is a deliberate behaviour change: it emits the analytic box-minus-boxes solid,
78/// which is MORE correct than the exact kernel on engulfing-opening walls.
79/// Corpus-validated ON (tests/rect_param_validate.rs A/B over 5 real models): every
80/// non-firing element byte-identical ON vs OFF, every firing host watertight and
81/// matching the analytic ground truth. wasm has no env, so `std::env::var` errs there
82/// and the default is ON on both targets -- native==wasm stays in lockstep; the
83/// wasm-side `setRectParamFastPath` override remains the programmatic escape hatch
84/// (and, being an explicit per-process call, takes precedence over both env flags).
85pub fn param_enabled() -> bool {
86 match PARAM_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed) {
87 0 => return false,
88 1 => return true,
89 _ => {}
90 }
91 use std::sync::OnceLock;
92 static ON: OnceLock<bool> = OnceLock::new();
93 *ON.get_or_init(|| {
94 param_env_default(enabled(), std::env::var("IFC_LITE_RECT_PARAM").as_deref().ok())
95 })
96}
97
98/// Env-flag decision core for [`param_enabled`], pure so the semantics are
99/// unit-testable without mutating process env: the parametric path is ON unless
100/// its own flag opts out (`IFC_LITE_RECT_PARAM=0`) or the global rect-fast kill
101/// switch is thrown (`IFC_LITE_RECT_FAST=0`, surfaced here as `rect_fast_on ==
102/// false` via [`enabled`]). `IFC_LITE_RECT_PARAM=1` cannot resurrect the path
103/// past the kill switch: the kill switch means "exact kernel for everything".
104fn param_env_default(rect_fast_on: bool, rect_param: Option<&str>) -> bool {
105 rect_fast_on && rect_param != Some("0")
106}
107
108/// Test-only: force `param_enabled()` on/off (or `None` for the env default).
109pub fn param_set_enabled_override(v: Option<bool>) {
110 PARAM_OVERRIDE.store(
111 match v {
112 None => -1,
113 Some(false) => 0,
114 Some(true) => 1,
115 },
116 std::sync::atomic::Ordering::Relaxed,
117 );
118}
119
120static PARAM_FIRES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
121
122/// Count one parametric fast-path fire (the analytic cut was emitted).
123pub fn param_record_fire() {
124 PARAM_FIRES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
125}
126/// Read + reset the parametric fast-path fire counter.
127pub fn take_param_fires() -> u64 {
128 PARAM_FIRES.swap(0, std::sync::atomic::Ordering::Relaxed)
129}
130
131// rect_fast engagement counters are now REQUEST-LOCAL: each cut records into its
132// router via `GeometryRouter::record_rect_fast` (drained by `take_rect_fast_stats`),
133// so concurrent native geometry passes get isolated per-load `rectFast` diagnostics.
134// The previous process-global atomic sink (`record_global` / `take_global_stats`)
135// was removed because it cross-contaminated concurrent loads.
136
137/// An axis-aligned box AABB extracted from a host mesh, plus a check that every
138/// face is axis-aligned (the precondition for the world-coord cut).
139struct AlignedBox {
140 min: [f64; 3],
141 max: [f64; 3],
142}
143
144/// Verify `mesh` is an axis-aligned box (every triangle normal ≈ ±X/±Y/±Z, and
145/// the surface spans exactly the AABB on all 6 sides) and return its AABB.
146/// Conservative: any deviation ⇒ `None` ⇒ defer to the exact kernel.
147fn aligned_box(mesh: &Mesh) -> Option<AlignedBox> {
148 if mesh.indices.len() < 36 {
149 // a box is ≥ 12 triangles; fewer can't be a closed box
150 return None;
151 }
152 let p = |i: u32| -> [f64; 3] {
153 let b = i as usize * 3;
154 [
155 mesh.positions[b] as f64,
156 mesh.positions[b + 1] as f64,
157 mesh.positions[b + 2] as f64,
158 ]
159 };
160 let (mn_f, mx_f) = mesh.bounds();
161 let min = [mn_f.x as f64, mn_f.y as f64, mn_f.z as f64];
162 let max = [mx_f.x as f64, mx_f.y as f64, mx_f.z as f64];
163 // Degenerate extent on any axis ⇒ not a 3D box.
164 for k in 0..3 {
165 if max[k] - min[k] <= SNAP_GRID {
166 return None;
167 }
168 }
169 // Every triangle must be axis-aligned (normal along one axis) AND lie on the
170 // corresponding min or max face plane — i.e. the mesh is exactly the AABB
171 // shell, nothing protruding or interior.
172 const PLANE_TOL: f64 = 1e-4;
173 let mut seen_face = [false; 6]; // -x,+x,-y,+y,-z,+z
174 for tri in mesh.indices.chunks_exact(3) {
175 let (a, b, c) = (p(tri[0]), p(tri[1]), p(tri[2]));
176 let e1 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
177 let e2 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
178 let n = [
179 e1[1] * e2[2] - e1[2] * e2[1],
180 e1[2] * e2[0] - e1[0] * e2[2],
181 e1[0] * e2[1] - e1[1] * e2[0],
182 ];
183 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
184 if len == 0.0 {
185 continue; // degenerate tri — ignore (hygiene drops it anyway)
186 }
187 // which axis is the normal along?
188 let mut axis = usize::MAX;
189 for k in 0..3 {
190 if (n[k].abs() / len) > 0.999 {
191 axis = k;
192 }
193 }
194 if axis == usize::MAX {
195 return None; // a non-axis-aligned face ⇒ not an aligned box
196 }
197 // all 3 verts must sit on the min or max plane of that axis
198 let on_min = a[axis] <= min[axis] + PLANE_TOL
199 && b[axis] <= min[axis] + PLANE_TOL
200 && c[axis] <= min[axis] + PLANE_TOL;
201 let on_max = a[axis] >= max[axis] - PLANE_TOL
202 && b[axis] >= max[axis] - PLANE_TOL
203 && c[axis] >= max[axis] - PLANE_TOL;
204 if on_min {
205 seen_face[axis * 2] = true;
206 } else if on_max {
207 seen_face[axis * 2 + 1] = true;
208 } else {
209 return None; // an interior / protruding triangle ⇒ not a clean box
210 }
211 }
212 if seen_face.iter().all(|&s| s) {
213 Some(AlignedBox { min, max })
214 } else {
215 None
216 }
217}
218
219/// Result of the cut: a watertight `Mesh`, or `None` to defer to the exact
220/// kernel. `openings` are world AABBs (min,max) of axis-aligned rectangular
221/// cutters. Handles WINDOWS, DOORS (flush to an edge), recesses, and overlapping
222/// openings uniformly via a 3D CELLULAR decomposition: split the host box by
223/// every opening plane on all three axes, mark each cell solid (in the host,
224/// outside every opening) or void, and emit each cell face that borders a void
225/// cell or the grid boundary. Watertight by construction — adjacent cells share
226/// snapped grid vertices bit-identically, so no T-junctions and no cracks.
227pub fn subtract_rect_openings(
228 host: &Mesh,
229 openings: &[([f64; 3], [f64; 3])],
230 stats: &mut RectFastStats,
231) -> Option<Mesh> {
232 if openings.is_empty() {
233 stats.defer_no_openings += 1;
234 return None;
235 }
236 // build_cellular is O(cells * openings) and cells grow with the opening count,
237 // so a crafted host with thousands of tiny openings is O(N^3) -> unbounded
238 // hang. 128 is far above any real wall (1-20 openings); defer the pathological
239 // case to the exact kernel (bounded per-element, #1121) rather than hang.
240 const MAX_FAST_OPENINGS: usize = 128;
241 if openings.len() > MAX_FAST_OPENINGS {
242 stats.defer_too_many_openings += 1;
243 return None;
244 }
245 let bx = match aligned_box(host) {
246 Some(b) => b,
247 None => {
248 stats.defer_host_not_box += 1;
249 return None;
250 }
251 };
252
253 // Scale-aware near-edge epsilon: two grid lines closer than this would
254 // collapse into one f32 at the host's world magnitude, cracking the cut.
255 // max(|coord|)·2^-21 keeps >= 4 f32 ULP between distinct lines; floored at the
256 // snap grid so origin-scale hosts stay permissive.
257 let mag = (0..3)
258 .map(|k| bx.min[k].abs().max(bx.max[k].abs()))
259 .fold(0.0f64, f64::max);
260 let near_eps = (mag * (1.0 / 2_097_152.0)).max(SNAP_GRID);
261
262 // Clamp every opening to the host shell (a cutter extended through the wall
263 // pokes past) and keep only those that overlap on all 3 axes. A
264 // non-overlapping opening is a no-op (the SILENT NO-OP case) — drop it.
265 let smin = [snap(bx.min[0]), snap(bx.min[1]), snap(bx.min[2])];
266 let smax = [snap(bx.max[0]), snap(bx.max[1]), snap(bx.max[2])];
267 let mut clamped: Vec<[[f64; 3]; 2]> = Vec::with_capacity(openings.len());
268 for (omn, omx) in openings {
269 let mut cmn = [0.0; 3];
270 let mut cmx = [0.0; 3];
271 let mut overlaps = true;
272 let mut covers_full = true;
273 for k in 0..3 {
274 cmn[k] = snap(omn[k].max(bx.min[k]));
275 cmx[k] = snap(omx[k].min(bx.max[k]));
276 if cmx[k] - cmn[k] <= near_eps {
277 overlaps = false;
278 }
279 // Does the clamped opening reach the host edge-to-edge on this axis?
280 if cmn[k] > smin[k] + near_eps || cmx[k] < smax[k] - near_eps {
281 covers_full = false;
282 }
283 }
284 // An opening that CONTAINS the whole host on all three axes is a
285 // degenerate / double-encoded redundant void: the void is already baked
286 // into the wall profile and re-added as an opening element (#964). The
287 // exact kernel treats it as a no-op (coplanar cutter faces → host left
288 // unchanged), so the fast path MUST NOT cut it — doing so erases the
289 // entire wall and leaves the window floating in a giant void (#1167).
290 // Defer the whole host to the exact path to match its behaviour.
291 if covers_full {
292 stats.defer_off_face += 1;
293 return None;
294 }
295 if overlaps {
296 clamped.push([cmn, cmx]);
297 }
298 }
299 if clamped.is_empty() {
300 // Nothing actually cuts — defer the no-op to the exact path (it owns the
301 // SILENT NO-OP diagnostic) rather than returning an unchanged clone.
302 stats.defer_off_face += 1;
303 return None;
304 }
305
306 // Per-axis grid lines: host bounds + every clamped opening edge, snapped,
307 // sorted, deduplicated; near-coincident-but-distinct lines (< near_eps) →
308 // defer (f32-collapse risk).
309 let mut grid: [Vec<f64>; 3] = [Vec::new(), Vec::new(), Vec::new()];
310 for k in 0..3 {
311 let mut edges = vec![snap(bx.min[k]), snap(bx.max[k])];
312 for c in &clamped {
313 edges.push(c[0][k]);
314 edges.push(c[1][k]);
315 }
316 match dedup_axis(edges, near_eps) {
317 Some(g) => grid[k] = g,
318 None => {
319 stats.defer_near_edge += 1;
320 return None;
321 }
322 }
323 }
324
325 let cut = build_cellular(&grid, &clamped);
326 stats.fired += 1;
327 stats.openings_cut += clamped.len() as u64;
328 Some(cut)
329}
330
331/// Sort + dedup grid lines on one axis; `None` if two DISTINCT lines are closer
332/// than `near_eps` (an f32-collapse risk → defer). Identical snapped values
333/// (e.g. a door edge coinciding with the host edge) collapse to one line.
334fn dedup_axis(mut edges: Vec<f64>, near_eps: f64) -> Option<Vec<f64>> {
335 // A non-finite coord (NaN/Inf from a corrupt mesh) would panic the sort under
336 // `panic=abort` and crash the whole batch — defer to the exact kernel instead.
337 if edges.iter().any(|v| !v.is_finite()) {
338 return None;
339 }
340 edges.sort_by(|a, b| a.total_cmp(b));
341 let mut out: Vec<f64> = Vec::with_capacity(edges.len());
342 for c in edges {
343 match out.last() {
344 Some(&last) => {
345 let d = (c - last).abs();
346 if d == 0.0 {
347 // same line — keep one
348 } else if d < near_eps {
349 return None; // distinct but too close → collapse risk
350 } else {
351 out.push(c);
352 }
353 }
354 None => out.push(c),
355 }
356 }
357 if out.len() < 2 {
358 return None;
359 }
360 Some(out)
361}
362
363struct Builder {
364 positions: Vec<f32>,
365 normals: Vec<f32>,
366 indices: Vec<u32>,
367}
368
369impl Builder {
370 fn vert(&mut self, p: [f64; 3], n: [f32; 3]) -> u32 {
371 let idx = (self.positions.len() / 3) as u32;
372 self.positions.push(p[0] as f32);
373 self.positions.push(p[1] as f32);
374 self.positions.push(p[2] as f32);
375 self.normals.extend_from_slice(&n);
376 idx
377 }
378 /// A planar quad (4 coplanar world corners in perimeter order) emitted with
379 /// the winding that makes its front face point along `n` — robust to the
380 /// handedness of the (u,v,w) axis assignment (e.g. a Y-through wall is a
381 /// left-handed frame, which would otherwise invert every face).
382 fn quad(&mut self, c: [[f64; 3]; 4], n: [f32; 3]) {
383 let e1 = [c[1][0] - c[0][0], c[1][1] - c[0][1], c[1][2] - c[0][2]];
384 let e2 = [c[2][0] - c[0][0], c[2][1] - c[0][1], c[2][2] - c[0][2]];
385 let cr = [
386 e1[1] * e2[2] - e1[2] * e2[1],
387 e1[2] * e2[0] - e1[0] * e2[2],
388 e1[0] * e2[1] - e1[1] * e2[0],
389 ];
390 let dot = cr[0] * n[0] as f64 + cr[1] * n[1] as f64 + cr[2] * n[2] as f64;
391 let o = if dot >= 0.0 { [0, 1, 2, 3] } else { [0, 3, 2, 1] };
392 let v0 = self.vert(c[o[0]], n);
393 let v1 = self.vert(c[o[1]], n);
394 let v2 = self.vert(c[o[2]], n);
395 let v3 = self.vert(c[o[3]], n);
396 self.indices.extend_from_slice(&[v0, v1, v2, v0, v2, v3]);
397 }
398}
399
400/// 3D cellular exposed-face extraction. `grid[axis]` is the sorted snapped grid
401/// lines on that axis. A cell is solid iff its centre lies outside every opening
402/// (it is always inside the host); emit every face of a solid cell that borders
403/// a void cell or the grid boundary, with the outward normal — skipping faces
404/// internal to the solid region. Watertight: a shared face between two solid
405/// cells is emitted by neither; every boundary face is emitted exactly once on
406/// the snapped grid, so reverse edges cancel bit-for-bit.
407fn build_cellular(grid: &[Vec<f64>; 3], openings: &[[[f64; 3]; 2]]) -> Mesh {
408 let nc = [grid[0].len() - 1, grid[1].len() - 1, grid[2].len() - 1];
409 let center = |axis: usize, i: usize| (grid[axis][i] + grid[axis][i + 1]) * 0.5;
410 let solid = |c: [usize; 3]| -> bool {
411 let p = [center(0, c[0]), center(1, c[1]), center(2, c[2])];
412 !openings
413 .iter()
414 .any(|o| (0..3).all(|a| p[a] > o[0][a] && p[a] < o[1][a]))
415 };
416 let mut b = Builder {
417 positions: Vec::new(),
418 normals: Vec::new(),
419 indices: Vec::new(),
420 };
421
422 for i in 0..nc[0] {
423 for j in 0..nc[1] {
424 for k in 0..nc[2] {
425 let cell = [i, j, k];
426 if !solid(cell) {
427 continue;
428 }
429 for axis in 0..3 {
430 let (a1, a2) = match axis {
431 0 => (1usize, 2usize),
432 1 => (0usize, 2usize),
433 _ => (0usize, 1usize),
434 };
435 for &pos in &[false, true] {
436 let exposed = if pos {
437 cell[axis] == nc[axis] - 1 || !solid({
438 let mut n = cell;
439 n[axis] += 1;
440 n
441 })
442 } else {
443 cell[axis] == 0 || !solid({
444 let mut n = cell;
445 n[axis] -= 1;
446 n
447 })
448 };
449 if !exposed {
450 continue;
451 }
452 let coord = grid[axis][cell[axis] + usize::from(pos)];
453 let (lo1, hi1) = (grid[a1][cell[a1]], grid[a1][cell[a1] + 1]);
454 let (lo2, hi2) = (grid[a2][cell[a2]], grid[a2][cell[a2] + 1]);
455 let corner = |x1: f64, x2: f64| {
456 let mut q = [0.0; 3];
457 q[axis] = coord;
458 q[a1] = x1;
459 q[a2] = x2;
460 q
461 };
462 let mut n = [0.0f32; 3];
463 n[axis] = if pos { 1.0 } else { -1.0 };
464 b.quad(
465 [
466 corner(lo1, lo2),
467 corner(hi1, lo2),
468 corner(hi1, hi2),
469 corner(lo1, hi2),
470 ],
471 n,
472 );
473 }
474 }
475 }
476 }
477 }
478
479 let mut m = Mesh::new();
480 m.positions = b.positions;
481 m.normals = b.normals;
482 m.indices = b.indices;
483 m
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 /// Closed axis-aligned box, 12 outward triangles.
491 fn box_mesh(min: [f64; 3], max: [f64; 3]) -> Mesh {
492 let c = [
493 [min[0], min[1], min[2]],
494 [max[0], min[1], min[2]],
495 [max[0], max[1], min[2]],
496 [min[0], max[1], min[2]],
497 [min[0], min[1], max[2]],
498 [max[0], min[1], max[2]],
499 [max[0], max[1], max[2]],
500 [min[0], max[1], max[2]],
501 ];
502 let faces: [([usize; 4], [f32; 3]); 6] = [
503 ([0, 3, 2, 1], [0.0, 0.0, -1.0]),
504 ([4, 5, 6, 7], [0.0, 0.0, 1.0]),
505 ([0, 1, 5, 4], [0.0, -1.0, 0.0]),
506 ([2, 3, 7, 6], [0.0, 1.0, 0.0]),
507 ([0, 4, 7, 3], [-1.0, 0.0, 0.0]),
508 ([1, 2, 6, 5], [1.0, 0.0, 0.0]),
509 ];
510 let mut m = Mesh::new();
511 for (idx, n) in faces {
512 let b = (m.positions.len() / 3) as u32;
513 for &i in &idx {
514 m.positions.extend_from_slice(&[c[i][0] as f32, c[i][1] as f32, c[i][2] as f32]);
515 m.normals.extend_from_slice(&n);
516 }
517 m.indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]);
518 }
519 m
520 }
521
522 /// DIRECTED exact-f32-bit edge audit (the production crack detector,
523 /// mesh_bridge::exact_open_edges): a watertight oriented surface has every
524 /// directed edge cancelled by its reverse — catches both cracks AND
525 /// inconsistent winding.
526 fn open_edges(m: &Mesh) -> usize {
527 use std::collections::HashMap;
528 let key = |i: u32| {
529 let b = i as usize * 3;
530 (m.positions[b].to_bits(), m.positions[b + 1].to_bits(), m.positions[b + 2].to_bits())
531 };
532 let mut edges: HashMap<_, i64> = HashMap::new();
533 for t in m.indices.chunks_exact(3) {
534 for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
535 *edges.entry((key(a), key(b))).or_insert(0) += 1;
536 *edges.entry((key(b), key(a))).or_insert(0) -= 1;
537 }
538 }
539 edges.values().filter(|&&c| c != 0).count()
540 }
541
542 fn degenerate(m: &Mesh) -> usize {
543 let v = |i: u32| {
544 let b = i as usize * 3;
545 [m.positions[b], m.positions[b + 1], m.positions[b + 2]]
546 };
547 let mut n = 0;
548 for t in m.indices.chunks_exact(3) {
549 let (a, b, c) = (v(t[0]), v(t[1]), v(t[2]));
550 let e1 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
551 let e2 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
552 let cr = [
553 e1[1] * e2[2] - e1[2] * e2[1],
554 e1[2] * e2[0] - e1[0] * e2[2],
555 e1[0] * e2[1] - e1[1] * e2[0],
556 ];
557 if cr[0] * cr[0] + cr[1] * cr[1] + cr[2] * cr[2] == 0.0 {
558 n += 1;
559 }
560 }
561 n
562 }
563
564 /// Signed volume × 6 (divergence), about origin.
565 fn vol6(m: &Mesh) -> f64 {
566 let v = |i: u32| {
567 let b = i as usize * 3;
568 [m.positions[b] as f64, m.positions[b + 1] as f64, m.positions[b + 2] as f64]
569 };
570 let mut s = 0.0;
571 for t in m.indices.chunks_exact(3) {
572 let (a, b, c) = (v(t[0]), v(t[1]), v(t[2]));
573 let cr = [
574 b[1] * c[2] - b[2] * c[1],
575 b[2] * c[0] - b[0] * c[2],
576 b[0] * c[1] - b[1] * c[0],
577 ];
578 s += a[0] * cr[0] + a[1] * cr[1] + a[2] * cr[2];
579 }
580 s
581 }
582
583 // 4m × 0.2m × 3m wall (thin along Y). Opening boxes poke through Y.
584 fn wall(base: [f64; 3]) -> Mesh {
585 box_mesh(base, [base[0] + 4.0, base[1] + 0.2, base[2] + 3.0])
586 }
587 fn opening(base: [f64; 3], u0: f64, u1: f64, z0: f64, z1: f64) -> ([f64; 3], [f64; 3]) {
588 (
589 [base[0] + u0, base[1] - 0.1, base[2] + z0],
590 [base[0] + u1, base[1] + 0.3, base[2] + z1],
591 )
592 }
593
594 fn check_watertight(base: [f64; 3], openings: &[([f64; 3], [f64; 3])], label: &str) {
595 let host = wall(base);
596 let mut st = RectFastStats::default();
597 let cut = subtract_rect_openings(&host, openings, &mut st)
598 .unwrap_or_else(|| panic!("{label}: expected fast path to fire, deferred: {st:?}"));
599 assert_eq!(open_edges(&cut), 0, "{label}: not watertight");
600 assert_eq!(degenerate(&cut), 0, "{label}: degenerate triangles");
601 // Removed volume ≈ Σ opening∩host volume.
602 let removed = (vol6(&host) - vol6(&cut)) / 6.0;
603 let mut expect = 0.0;
604 let (hmn, hmx) = (base, [base[0] + 4.0, base[1] + 0.2, base[2] + 3.0]);
605 for (omn, omx) in openings {
606 let mut vv = 1.0;
607 for k in 0..3 {
608 vv *= (omx[k].min(hmx[k]) - omn[k].max(hmn[k])).max(0.0);
609 }
610 expect += vv;
611 }
612 // Volume tolerance scales with f32 ULP at the host's magnitude (a wall
613 // 220 km from origin carries ~12 mm of inherent f32 error per coordinate
614 // — true of ANY f32 mesh there, exact kernel included; the cut is still
615 // watertight). Relative 2% OR absolute scale-aware, whichever is looser.
616 let mag = base[0].abs().max(base[2].abs()).max(1.0);
617 let vtol = (expect * 0.02).max(mag * 2f64.powi(-23) * 6.0);
618 assert!(
619 (removed - expect).abs() < vtol,
620 "{label}: removed {removed} != expected {expect} (tol {vtol})"
621 );
622 }
623
624 /// Flag semantics (pure core, no env mutation, safe under parallel tests):
625 /// `IFC_LITE_RECT_FAST=0` is the GLOBAL kill switch and also disables the
626 /// parametric path (even against an explicit `IFC_LITE_RECT_PARAM=1`);
627 /// `IFC_LITE_RECT_PARAM=0` disables only the parametric path; both unset
628 /// means the parametric path defaults ON.
629 #[test]
630 fn param_env_default_honours_global_kill_switch() {
631 // Global kill switch thrown (IFC_LITE_RECT_FAST=0 => enabled() false).
632 assert!(!param_env_default(false, None));
633 assert!(!param_env_default(false, Some("1")));
634 assert!(!param_env_default(false, Some("0")));
635 // Parametric-only opt-out.
636 assert!(!param_env_default(true, Some("0")));
637 // Defaults ON; explicit "1" is also ON.
638 assert!(param_env_default(true, None));
639 assert!(param_env_default(true, Some("1")));
640 }
641
642 #[test]
643 fn single_opening_watertight_origin() {
644 check_watertight([0.0, 0.0, 0.0], &[opening([0.0, 0.0, 0.0], 1.5, 2.5, 0.5, 2.0)], "single-origin");
645 }
646
647 #[test]
648 fn single_opening_watertight_building_scale() {
649 check_watertight(
650 [221_534.0, 98_210.0, 47_001.0],
651 &[opening([221_534.0, 98_210.0, 47_001.0], 1.5, 2.5, 0.5, 2.0)],
652 "single-building",
653 );
654 }
655
656 #[test]
657 fn multi_opening_watertight() {
658 let base = [10.0, 5.0, 2.0];
659 let ops = [
660 opening(base, 0.4, 1.2, 0.5, 2.4),
661 opening(base, 1.6, 2.4, 0.5, 2.4),
662 opening(base, 2.8, 3.6, 0.5, 1.0),
663 ];
664 check_watertight(base, &ops, "multi-3");
665 }
666
667 #[test]
668 fn defers_non_box_host() {
669 // a tetrahedron-ish non-box host
670 let mut host = Mesh::new();
671 host.positions = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
672 host.normals = vec![0.0; 12];
673 host.indices = vec![0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3];
674 let mut st = RectFastStats::default();
675 assert!(subtract_rect_openings(&host, &[opening([0.0, 0.0, 0.0], 1.5, 2.5, 0.5, 2.0)], &mut st).is_none());
676 assert_eq!(st.defer_host_not_box, 1);
677 }
678
679 #[test]
680 fn defers_pathological_opening_count() {
681 // A crafted host with hundreds of tiny openings makes build_cellular
682 // O(N^3); the fast path must defer to the exact kernel, not hang.
683 let host = box_mesh([0.0, 0.0, 0.0], [10.0, 3.0, 0.3]);
684 let openings: Vec<([f64; 3], [f64; 3])> = (0..200)
685 .map(|i| {
686 let x = i as f64 * 0.04;
687 ([x, 0.5, -0.1], [x + 0.02, 1.0, 0.4])
688 })
689 .collect();
690 let mut st = RectFastStats::default();
691 assert!(subtract_rect_openings(&host, &openings, &mut st).is_none());
692 assert_eq!(st.defer_too_many_openings, 1);
693 }
694
695 #[test]
696 fn door_flush_to_floor_watertight() {
697 // DOOR: full thickness, z from BELOW the floor up to 2.0 → touches the
698 // wall's bottom edge (no sill reveal); the bottom face gets a hole. The
699 // case the face-based v1 deferred (off_face); cellular cuts it watertight.
700 let base = [0.0, 0.0, 0.0];
701 let door = (
702 [base[0] + 1.5, base[1] - 0.1, base[2] - 0.5],
703 [base[0] + 2.5, base[1] + 0.3, base[2] + 2.0],
704 );
705 check_watertight(base, &[door], "door-flush-floor");
706 }
707
708 #[test]
709 fn edge_notch_watertight() {
710 // opening that exceeds the wall's X extent → a notch flush with the right
711 // edge (no right jamb). Was an off_face defer; cellular handles it.
712 let base = [0.0, 0.0, 0.0];
713 let notch = (
714 [base[0] + 3.8, base[1] - 0.1, base[2] + 0.5],
715 [base[0] + 4.5, base[1] + 0.3, base[2] + 2.0],
716 );
717 check_watertight(base, &[notch], "edge-notch");
718 }
719
720 #[test]
721 fn recess_cut_watertight() {
722 // RECESS: does NOT span the full thickness (a niche/pocket). Was a
723 // not_through defer; cellular cuts the pocket watertight.
724 let base = [0.0, 0.0, 0.0];
725 let recess = (
726 [base[0] + 1.5, base[1] + 0.05, base[2] + 0.5],
727 [base[0] + 2.5, base[1] + 0.15, base[2] + 2.0],
728 );
729 check_watertight(base, &[recess], "recess");
730 }
731
732 #[test]
733 fn opening_fully_outside_defers() {
734 // genuine no-op: opening entirely outside the host → no overlap → defer.
735 let base = [0.0, 0.0, 0.0];
736 let outside = (
737 [base[0] + 10.0, base[1] - 0.1, base[2] + 0.5],
738 [base[0] + 11.0, base[1] + 0.3, base[2] + 2.0],
739 );
740 let mut st = RectFastStats::default();
741 assert!(subtract_rect_openings(&wall(base), &[outside], &mut st).is_none());
742 }
743
744 #[test]
745 fn defers_near_edge_at_building_scale() {
746 // 8 µm reveal at ~220 km: below f32 ULP → must defer (the robustness gate)
747 let base = [221_534.0, 98_210.0, 47_001.0];
748 let tight = opening(base, 8e-6, 4.0 - 8e-6, 8e-6, 3.0 - 8e-6);
749 let mut st = RectFastStats::default();
750 assert!(
751 subtract_rect_openings(&wall(base), &[tight], &mut st).is_none(),
752 "near-edge at building scale must defer, not crack"
753 );
754 }
755
756 #[test]
757 fn deterministic_output() {
758 let base = [10.0, 5.0, 2.0];
759 let ops = [opening(base, 1.5, 2.5, 0.5, 2.0)];
760 let mut s1 = RectFastStats::default();
761 let mut s2 = RectFastStats::default();
762 let a = subtract_rect_openings(&wall(base), &ops, &mut s1).unwrap();
763 let b = subtract_rect_openings(&wall(base), &ops, &mut s2).unwrap();
764 assert_eq!(a.positions, b.positions);
765 assert_eq!(a.indices, b.indices);
766 }
767
768 #[test]
769 fn redundant_whole_wall_opening_defers() {
770 // #1167: a double-encoded void (#964) whose box CONTAINS the whole wall
771 // — full width × full height, through the thickness. The exact kernel
772 // treats this as a no-op (coplanar cutter → host unchanged), so the fast
773 // path MUST defer; cutting it would erase the entire wall and leave the
774 // window floating in a giant void.
775 let base = [10.0, 5.0, 2.0];
776 let whole = opening(base, 0.0, 4.0, 0.0, 3.0); // spans the full 4 m × 3 m face
777 let mut st = RectFastStats::default();
778 assert!(
779 subtract_rect_openings(&wall(base), &[whole], &mut st).is_none(),
780 "opening that contains the whole host must defer, not erase the wall"
781 );
782 assert_eq!(st.defer_off_face, 1);
783
784 // A whole-wall opening MIXED with a genuine window still defers the host
785 // (the redundant void poisons the batch → hand the whole element to the
786 // exact path, which cuts the real window and no-ops the redundant one).
787 let mut st_mix = RectFastStats::default();
788 assert!(
789 subtract_rect_openings(
790 &wall(base),
791 &[whole, opening(base, 0.5, 1.5, 0.4, 2.6)],
792 &mut st_mix
793 )
794 .is_none(),
795 "a redundant whole-wall opening must defer the whole host"
796 );
797
798 // Sanity: a genuine interior window (margin on every in-face axis) still
799 // fires the fast path — the guard is not over-broad.
800 let mut st_ok = RectFastStats::default();
801 assert!(
802 subtract_rect_openings(&wall(base), &[opening(base, 0.5, 3.5, 0.4, 2.6)], &mut st_ok)
803 .is_some(),
804 "an interior window must still fire the fast path"
805 );
806 }
807}