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