ifc_lite_geometry/kernel/mesh_bridge.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//! Bridge between the pure-Rust kernel (which works on `Tri = [[f64;3];3]`) and
6//! ifc-lite's `Mesh` (f32 positions/normals/indices). `subtract`/`union`/
7//! `intersection` here are what the `ClippingProcessor` seam calls.
8
9use super::arrangement::{
10 boolean, boolean_with_conformity, difference_all, difference_all_lenient,
11 difference_all_lenient_with_conformity, BoolOp,
12 Tri,
13};
14use super::signed_volume::{signed_volume6, signed_volume6_about, volume_reference};
15use crate::mesh::Mesh;
16
17/// f32-near-coplanar reconciliation snap grid, in the CALLER's unit — NOT
18/// metres (#2684): 15 µm on the METRE path (`router/voids`), 15 nm on the
19/// FILE-UNIT boolean path. Past |c| = 128 CALLER UNITS the f32 spacing is
20/// itself a multiple of the grid, so every f32 is already on it and the snap
21/// is INERT — 12.8 cm in a millimetre file (so all of it), 128 m in a metre
22/// one. `csg/plane_eps.rs` records the same divergence for the clipper's
23/// floor; `tests/snap_grid_unit_denomination.rs` measures both. A POWER OF TWO
24/// so `(c/G).round()*G` is EXACT f64 ⇒ bit-deterministic across
25/// x86_64/aarch64/wasm. Real IFC is f32-authored, so an intended-flush face is
26/// NOT coplanar after import; the grid is what makes it so.
27///
28/// Canonical definition — `tritri` and `arrangement` size their near-coplanar
29/// bands to the scatter envelope this snap produces, so they import this
30/// constant rather than mirroring it.
31pub(crate) const SNAP_GRID: f64 = 1.0 / 65536.0;
32
33#[inline]
34fn snap(c: f64) -> f64 {
35 (c / SNAP_GRID).round() * SNAP_GRID
36}
37
38// The cross-operand near-coincidence weld lives in `super::plane_weld`: it was
39// split out of this module when #3353 made it a boolean-wide concern rather
40// than a subtraction-only one, and this module was at its size budget.
41use super::plane_weld::{promote_cutter_verts_onto_host_faces, promote_operands_mutually};
42
43/// `Mesh` → the kernel's triangle list (f32 → f64, snapped to the reconcile
44/// grid). Panic-free: an out-of-range index OR a non-finite (NaN/Inf) coord drops
45/// that triangle rather than indexing past the end or crashing
46/// `BigRational::from_float` deep in the predicates (the two empirically-found
47/// reachable panic sites).
48pub fn mesh_to_tris(m: &Mesh) -> Vec<Tri> {
49 let vertex = |i: u32| -> Option<[f64; 3]> {
50 let b = (i as usize) * 3;
51 let c = [
52 *m.positions.get(b)? as f64,
53 *m.positions.get(b + 1)? as f64,
54 *m.positions.get(b + 2)? as f64,
55 ];
56 if !c.iter().all(|v| v.is_finite()) {
57 return None;
58 }
59 Some([snap(c[0]), snap(c[1]), snap(c[2])])
60 };
61 m.indices
62 .chunks_exact(3)
63 .filter_map(|c| Some([vertex(c[0])?, vertex(c[1])?, vertex(c[2])?]))
64 .collect()
65}
66
67/// The triangle's supporting plane in f64: unit normal + offset (`n . v0`),
68/// BEFORE any f32 cast. `(0,0,1,0)`-ish degenerate fallback for a
69/// zero-area triangle, matching `face_normal`'s old `[0,0,1]` default.
70fn face_plane(t: &Tri) -> ([f64; 3], f64) {
71 let e1 = [t[1][0] - t[0][0], t[1][1] - t[0][1], t[1][2] - t[0][2]];
72 let e2 = [t[2][0] - t[0][0], t[2][1] - t[0][1], t[2][2] - t[0][2]];
73 let n = [
74 e1[1] * e2[2] - e1[2] * e2[1],
75 e1[2] * e2[0] - e1[0] * e2[2],
76 e1[0] * e2[1] - e1[1] * e2[0],
77 ];
78 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
79 if len > 0.0 {
80 let n = [n[0] / len, n[1] / len, n[2] / len];
81 let d = n[0] * t[0][0] + n[1] * t[0][1] + n[2] * t[0][2];
82 (n, d)
83 } else {
84 ([0.0, 0.0, 1.0], t[0][2])
85 }
86}
87
88/// The kernel's triangle list → a `Mesh` (per-face flat normals, f64 → f32).
89///
90/// Also tags every output triangle with its f64 supporting plane
91/// ([`PlaneTag`], issue #3914) — the exact plane this function's own f64
92/// arithmetic derived, before the `positions`/`normals` f32 cast below loses
93/// precision. Union output carries the tags through this entry point;
94/// difference/intersection output uses [`tris_to_mesh_without_plane_tags`]
95/// because its consolidated result can become another boolean operand (#5012).
96pub fn tris_to_mesh(tris: &[Tri]) -> Mesh {
97 tris_to_mesh_impl(tris, true)
98}
99
100fn tris_to_mesh_without_plane_tags(tris: &[Tri]) -> Mesh {
101 tris_to_mesh_impl(tris, false)
102}
103
104fn tris_to_mesh_impl(tris: &[Tri], carry_plane_tags: bool) -> Mesh {
105 use crate::mesh::PlaneTag;
106 let mut m = Mesh::with_capacity(tris.len() * 3, tris.len() * 3);
107 let mut tags = Vec::with_capacity(tris.len());
108 for t in tris {
109 let (n64, d64) = face_plane(t);
110 let n = [n64[0] as f32, n64[1] as f32, n64[2] as f32];
111 let base = (m.positions.len() / 3) as u32;
112 for p in t {
113 m.positions
114 .extend_from_slice(&[p[0] as f32, p[1] as f32, p[2] as f32]);
115 m.normals.extend_from_slice(&n);
116 }
117 m.indices.extend_from_slice(&[base, base + 1, base + 2]);
118 tags.push(PlaneTag { n: n64, d: d64 });
119 }
120 if carry_plane_tags {
121 m.plane_tags = Some(tags);
122 }
123 m
124}
125
126/// Orient a closed operand OUTWARD before it enters the arrangement.
127///
128/// The kernel boolean (`boolean_vids` / `union_all`) derives its keep/flip rules
129/// from the OUTWARD-normal convention (own-solid on `−n`; the difference flips the
130/// kept B faces so their caps seam with A). Real IFC winding is NOT reliably
131/// outward — a CW profile extruded along `+Z`, or a faceted brep with inconsistent
132/// face loops, yields an INWARD-wound (negative-signed-volume) closed solid. Fed
133/// in as-is it tears the result: open boundary edges along the cut rim + an
134/// inverted-volume surface (the 1007 gable-wall slivers; #1007 defect A).
135///
136/// We flip winding (`[a,b,c] → [a,c,b]`, an EXACT index swap) iff the signed
137/// volume is negative, so every operand the kernel sees is outward. The flip is a
138/// no-op for already-outward inputs (every pinned box−box manifest: `cube_mesh`
139/// has volume `+8`/`+27`), so determinism manifests are unperturbed.
140pub(crate) fn orient_outward(mut tris: Vec<Tri>) -> Vec<Tri> {
141 if signed_volume6(&tris) < 0.0 {
142 for t in &mut tris {
143 t.swap(1, 2);
144 }
145 }
146 tris
147}
148
149/// `host − cutter` as a `Mesh`.
150pub fn subtract(host: &Mesh, cutter: &Mesh) -> Mesh {
151 subtract_with_change(host, cutter).0
152}
153
154/// Like [`subtract`], but also returns the classifier's `changed` bit (#4692):
155/// `false` means no host sub-triangle was dropped and no cutter face was kept,
156/// so the triangles are the host re-tessellated, not a cut. [`subtract_many`]
157/// reads the same bit.
158///
159/// A one-component [`difference_all_lenient`] is the binary
160/// `boolean(.., Difference)`: the same arrangement over the same operands,
161/// classified through the same one-component `BComponents`, and neither gates
162/// on conformity. The third element says whether the arrangement conformed.
163pub(crate) fn subtract_with_change(host: &Mesh, cutter: &Mesh) -> (Mesh, bool, bool) {
164 #[cfg(feature = "csg_capture")]
165 crate::csg_capture::record_single(host, cutter);
166 let h = orient_outward(mesh_to_tris(host));
167 let mut c = mesh_to_tris(cutter);
168 promote_cutter_verts_onto_host_faces(&mut c, &h);
169 let c = orient_outward(c);
170 let (tris, changed, conforming) = difference_all_lenient_with_conformity(&h, &[&c]);
171 (tris_to_mesh_without_plane_tags(&tris), changed, conforming)
172}
173
174/// What [`subtract_many`] made of a cutter group.
175#[must_use]
176#[derive(Debug, Clone)]
177pub enum BatchSubtract {
178 /// The arrangement conformed (or its lenient batch passed the volume
179 /// oracle) and at least one cutter reaches the host solid. Empty when the
180 /// cutters engulf the host.
181 Cut(Mesh),
182 /// The arrangement conformed but no cutter reaches the host solid: every
183 /// host sub-triangle was kept and no cutter face was. The host is unchanged.
184 Unchanged,
185 /// No trustworthy arrangement: an unrecovered constraint remained and the
186 /// lenient batch failed the volume oracle, or the oracle itself tripped the
187 /// budget. The caller falls back to sequential per-cutter subtraction.
188 Nonconforming,
189}
190
191impl BatchSubtract {
192 /// `Cut` when the classifier changed the host, `Unchanged` otherwise.
193 fn classified(tris: &[Tri], changed: bool) -> Self {
194 if changed {
195 Self::Cut(tris_to_mesh_without_plane_tags(tris))
196 } else {
197 Self::Unchanged
198 }
199 }
200}
201
202/// `host − (∪ cutters)` as a `Mesh` — the batched void-group subtract.
203///
204/// The cutters MUST be pairwise disjoint (the router groups by snap-band-
205/// inflated AABBs) and each per-component watertight. Every component is
206/// promoted onto the host faces and oriented outward INDIVIDUALLY — the global
207/// signed-volume orientation of [`subtract`] cannot fix mixed per-component
208/// winding of a multi-component operand (the #2176 lesson) — then the whole
209/// group is subtracted in ONE arrangement (`difference_all_volume_safe`), so
210/// there is no per-cutter f64→f32→snap round-trip to re-jitter and re-crack the
211/// previous cut's seams. Component order is the caller's (deterministic).
212/// See [`BatchSubtract`] for the three outcomes.
213pub fn subtract_many(host: &Mesh, cutters: &[&Mesh]) -> BatchSubtract {
214 subtract_many_with_conformity(host, cutters, false).0
215}
216
217/// Keep the provenance of an unchanged batch for the void router (#6516).
218/// A volume-checked nonconforming miss must retain the sequential fallback.
219pub(crate) fn subtract_many_with_conformity(
220 host: &Mesh, cutters: &[&Mesh], retain_conforming_miss: bool,
221) -> (BatchSubtract, bool, Option<Mesh>) {
222 #[cfg(feature = "csg_capture")]
223 crate::csg_capture::record_many(host, cutters);
224 let h = orient_outward(mesh_to_tris(host));
225 let comp_tris: Vec<Vec<Tri>> = cutters
226 .iter()
227 .map(|m| {
228 let mut c = mesh_to_tris(m);
229 promote_cutter_verts_onto_host_faces(&mut c, &h);
230 orient_outward(c)
231 })
232 .collect();
233 let refs: Vec<&[Tri]> = comp_tris.iter().map(|c| c.as_slice()).collect();
234 // Conforming batch: the fast, exact, byte-identical common path.
235 if let Some((r, changed)) = difference_all(&h, &refs) {
236 #[cfg(feature = "opening-perf-trace")]
237 crate::opening_perf_trace::record(|c| {
238 c.batch_conforming_arrangements = c.batch_conforming_arrangements.saturating_add(1);
239 c.batch_conforming_misses = c.batch_conforming_misses.saturating_add(u64::from(!changed));
240 });
241 // Only the private void-group caller requests this: an unchanged
242 // arrangement still carries useful seams for topology consolidation.
243 let miss = (retain_conforming_miss && !changed)
244 .then(|| tris_to_mesh_without_plane_tags(&r));
245 return (BatchSubtract::classified(&r, changed), true, miss);
246 }
247 #[cfg(feature = "opening-perf-trace")]
248 crate::opening_perf_trace::record(|c| c.batch_nonconforming_arrangements = c.batch_nonconforming_arrangements.saturating_add(1));
249 // Non-conforming batch (an unrecovered constraint remains after the robust
250 // traversal recovery). Its exact topology is CLEANER than sequential per-cutter
251 // re-jitter on dense faceted-reveal walls (issue #098 V5C: 532→108 open edges),
252 // but a straddling misclassification can over/under-cut VOLUME (#559171/#1167).
253 // Trust the lenient batch ONLY when its removed volume matches the true removed
254 // volume; else Nonconforming, so the caller runs its full sequential path.
255 let (batch, changed) = difference_all_lenient(&h, &refs);
256 // ORACLE for the volume comparison (#1788): batched cutters are pairwise
257 // disjoint (this fn's contract), so the TRUE removed volume is
258 // Σ |host ∩ cutterᵢ| — each a small single boolean against the PRISTINE
259 // host, the well-conditioned regime. The previous oracle re-ran the
260 // sequential subtract chain, but that chain re-jitters its own seams
261 // cut-over-cut and UNDER-cuts on multi-void walls — on the ISSUE_098
262 // Poroton wall its "reference" removed 2% less volume than the (correct)
263 // batch, so a perfect batch was rejected in favour of the broken
264 // sequential fallback, leaving an opening uncut (T6 fail:opening-not-cut).
265 // Budget snapshot/restore: oracle work isn't charged to the caller's
266 // batch budget (codex P2 on #1660); a trip DURING an oracle intersection
267 // makes its volume untrustworthy ⇒ reject the group (as before).
268 let budget_snap = super::budget::snapshot_counters();
269 let mut inter_sum = 0.0f64;
270 let mut oracle_tripped = false;
271 for c in &comp_tris {
272 super::budget::begin();
273 let i = boolean(&h, c, BoolOp::Intersection);
274 if super::budget::tripped() {
275 oracle_tripped = true;
276 break;
277 }
278 inter_sum += signed_volume6(&i).abs();
279 }
280 super::budget::restore_counters(budget_snap);
281 if oracle_tripped {
282 return (BatchSubtract::Nonconforming, false, None);
283 }
284 // Both readings about the HOST's reference point (#4693). A host can arrive
285 // open (the router does not require a closed host), and an open surface's
286 // sum moves with the reference point: read the batch about its own centre
287 // and a cut that trims the host's bounding box leaves the untouched crack's
288 // flux in `batch_removed`.
289 let reference = volume_reference(&h);
290 let host_v = signed_volume6_about(&h, &reference).abs();
291 let batch_removed = host_v - signed_volume6_about(&batch, &reference).abs();
292 // 1% agreement — above f64/FMA noise (parity-stable branch), tight enough to
293 // reject the #1167 gross under-cut (3.7 m³ vs 13 m³).
294 let tol = inter_sum.abs().max(1.0e-9) * 0.01;
295 if (batch_removed - inter_sum).abs() <= tol {
296 #[cfg(feature = "opening-perf-trace")]
297 crate::opening_perf_trace::record(|c| c.batch_volume_checked_misses = c.batch_volume_checked_misses.saturating_add(u64::from(!changed)));
298 (BatchSubtract::classified(&batch, changed), false, None)
299 } else {
300 (BatchSubtract::Nonconforming, false, None)
301 }
302}
303
304/// `a ∪ b` as a `Mesh`.
305pub fn union(a: &Mesh, b: &Mesh) -> Mesh {
306 union_with_conformity(a, b).0
307}
308
309/// Like [`union`], but also reports arrangement conformity (`unrecovered == 0`).
310/// Diagnostics only (#3353); `union()` computes and discards this same signal.
311pub fn union_with_conformity(a: &Mesh, b: &Mesh) -> (Mesh, bool) {
312 // #1109 budget, as `subtract` does: fresh PER-BOOLEAN count, per-ELEMENT accumulator
313 // intact — `begin()` resets only the per-op counter, so a union inside an over-budget
314 // element STILL trips (see `budget::begin`). Without it a union after a tripped
315 // subtract starts tripped and `arrange` bails at its first pair.
316 super::budget::begin();
317 // Reconcile the two operands' near-coplanar faces onto shared planes BEFORE
318 // the arrangement (#3353). Mutual, not cutter-onto-host: union has no host,
319 // and the one-directional form left `union_mesh(b, a)` tearing on the same
320 // fixture `union_mesh(a, b)` handled. See `promote_operands_mutually`.
321 let mut operands = [mesh_to_tris(a), mesh_to_tris(b)];
322 promote_operands_mutually(&mut operands);
323 let [ta, tb] = operands;
324 let (a, b) = (orient_outward(ta), orient_outward(tb));
325 let (out, conforming) = boolean_with_conformity(&a, &b, BoolOp::Union);
326 // On a trip `out` is PARTIAL: discard it, return empty — the graceful fallback callers
327 // handle (`csg::union_mesh` merges plainly; #960 goes sequential), never a poisoned
328 // mesh. The trip point is a pure function of the snapped operands, so wasm agrees.
329 if super::budget::tripped() {
330 return (Mesh::new(), conforming);
331 }
332 (tris_to_mesh(&out), conforming)
333}
334
335#[path = "nary_union.rs"]
336mod nary_union;
337pub use nary_union::union_many;
338pub(crate) use nary_union::union_many_preserving_coordinates;
339
340/// `a ∩ b` as the kernel's own exact f64 triangles, WITHOUT the `Mesh` round-trip.
341///
342/// `Mesh` stores positions as `f32`, so `intersection` below loses ~1e-7 of
343/// relative precision on the way out. That is irrelevant for a render buffer and
344/// very relevant for anything that measures the result: on the analytic
345/// rotated-box oracle the f64 triangles give exactly 9.375 m³ where the `Mesh`
346/// round-trip gives 9.374999882. Callers that report a VOLUME (the clash
347/// intersection solid, `crate::clash_solid`) take this entry; callers that only
348/// need triangles to draw take `intersection`.
349///
350/// Empty on a #1109 budget trip, exactly like `intersection`.
351pub fn intersection_tris(a: &Mesh, b: &Mesh) -> Vec<Tri> {
352 // Participate in the #1109 budget like `subtract` / `union` — fresh per-boolean
353 // count, per-element accumulator preserved (see `union`).
354 super::budget::begin();
355 let a = orient_outward(mesh_to_tris(a));
356 let b = orient_outward(mesh_to_tris(b));
357 let out = boolean(&a, &b, BoolOp::Intersection);
358 // On a budget trip `arrange` bailed and `out` is a PARTIAL arrangement. Return
359 // empty — `csg::intersection_mesh` treats empty as the graceful (disjoint-like)
360 // degrade rather than consuming a poisoned partial intersection.
361 if super::budget::tripped() {
362 return Vec::new();
363 }
364 out
365}
366
367/// `a ∩ b` as a `Mesh`.
368pub fn intersection(a: &Mesh, b: &Mesh) -> Mesh {
369 tris_to_mesh_without_plane_tags(&intersection_tris(a, b))
370}
371
372#[cfg(test)]
373#[path = "mesh_bridge_tests.rs"]
374mod tests;
375
376#[cfg(test)]
377#[path = "issue_3913_sweep_tests.rs"]
378mod issue_3913_sweep_tests;