ifc_lite_geometry/kernel/arrangement/boolean.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
5use super::super::broadphase::tris_aabb;
6use super::super::interner::{Interner, Vid};
7use super::super::rational::point_of;
8use super::classify::{
9 boolean_vids, boolean_vids_components, cross3, rotate_min_first, sub_f64, to_f64_pt,
10 BComponents,
11};
12use super::ray_parity::{operand_extent, point_inside};
13use super::{arrange, arrange_many, Arrangement, BoolOp, MultiArrangement, Tri};
14use num_traits::ToPrimitive;
15
16/// `∪ meshes` as a watertight triangle list — the N-ary union.
17///
18/// Computed in ONE conforming arrangement ([`arrange_many`]), so it never re-meshes
19/// an intermediate union (which is what makes left-deep pairwise accumulation tear
20/// along coplanar seams shared by 3+ operands — the #960 segmented-roof cutters).
21///
22/// A sub-triangle of mesh `k` is on `∂(∪meshes)` iff its OUTER side (`+n`) lies
23/// outside every other mesh. Identical co-oriented faces shared by several meshes
24/// (e.g. duplicated cutter prisms) are kept once, owned by the LOWEST mesh index.
25///
26/// Returns `(union, conforming)`. `conforming == false` ⇒ the N-way arrangement
27/// left an unrecovered constraint ([`MultiArrangement::unrecovered`]): some
28/// sub-triangle STRADDLES another operand's boundary and its centroid
29/// classification is unreliable, so the union may be TORN. This is the SAME
30/// non-conformity condition [`difference_all`] HARD-REJECTS — surfaced here as a
31/// signal a torn union is distinct from an EMPTY one, where before it was
32/// silently discarded (`arrange_many`'s `_unrecovered`) and indistinguishable.
33///
34/// Unlike `difference_all`, the union still RETURNS its best-effort triangles even
35/// when `!conforming`: its sole caller (the #960 segmented-roof cutter-union via
36/// [`crate::kernel::mesh_bridge::union_many`]) verifies the DOWNSTREAM subtract,
37/// and the exact batched union — even a technically torn one — is a strictly
38/// better cutter than the sequential fallback, which reintroduces the seam sliver
39/// #960 eliminated (House.ifc wall #4148: exact union → correct 8984 mm; the
40/// sequential fallback → a 9850 mm full-height sliver). A caller WITHOUT its own
41/// output verification must treat `!conforming` as "do not trust; fall back."
42pub fn union_all(meshes: &[&[Tri]]) -> (Vec<Tri>, bool) {
43 if meshes.is_empty() {
44 return (Vec::new(), true);
45 }
46 if meshes.len() == 1 {
47 return (meshes[0].to_vec(), true);
48 }
49 use std::collections::HashMap;
50 let arr = arrange_many(meshes);
51 let conforming = arr.unrecovered == 0;
52 let exts: Vec<f64> = meshes.iter().map(|m| operand_extent(m)).collect();
53 // Each mesh's exact bounding box, hoisted once instead of having
54 // `point_inside` rescan `meshes[m]` on every OTHER mesh's sub-triangle probe
55 // against it below (an O(N) rescan repeated per-probe, not just per-mesh).
56 let aabbs: Vec<_> = meshes.iter().map(|m| tris_aabb(m)).collect();
57 // Owner map: oriented (winding-preserving) Vid key → lowest mesh index that
58 // KEEPS that face. A later mesh's identical co-oriented copy is dropped.
59 let mut owner: HashMap<[Vid; 3], usize> = HashMap::new();
60 // First pass: decide keep per sub-triangle (boundary of the union), recording
61 // the canonical owner of each kept oriented face.
62 let mut keep: Vec<Vec<bool>> = Vec::with_capacity(meshes.len());
63 for (k, sub) in arr.subtris.iter().enumerate() {
64 let mut kk = Vec::with_capacity(sub.len());
65 for &tri in sub {
66 let c = centroid_multi(&arr, tri);
67 let n = tri_normal_multi(&arr, tri);
68 // outer side just past the face along +n
69 let outer = offset_point(c, n, exts[k]);
70 // on the union boundary iff the outer side is outside ALL other meshes
71 let on_boundary = (0..meshes.len())
72 .filter(|&m| m != k)
73 .all(|m| !point_inside(outer, meshes[m], exts[m], aabbs[m]));
74 let mut keep_this = on_boundary;
75 if keep_this {
76 let key = rotate_min_first(tri);
77 match owner.get(&key) {
78 Some(&o) if o != k => keep_this = false, // duplicate; earlier mesh owns it
79 _ => {
80 owner.entry(key).or_insert(k);
81 }
82 }
83 }
84 kk.push(keep_this);
85 }
86 keep.push(kk);
87 }
88 // Materialize kept sub-triangles to f64.
89 let mut out = Vec::new();
90 for (k, sub) in arr.subtris.iter().enumerate() {
91 for (t, &tri) in sub.iter().enumerate() {
92 if keep[k][t] {
93 out.push([
94 point_via_interner(&arr.interner, tri[0]),
95 point_via_interner(&arr.interner, tri[1]),
96 point_via_interner(&arr.interner, tri[2]),
97 ]);
98 }
99 }
100 }
101 (out, conforming)
102}
103
104/// Step `step·n̂` off `c` along the unit-normalised `dir`. Deterministic FMA-free
105/// f64 (mirrors [`solid_side`]'s probe).
106fn offset_point(c: [f64; 3], dir: [f64; 3], far_l: f64) -> [f64; 3] {
107 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
108 if len == 0.0 {
109 return c;
110 }
111 let step = far_l * (1.0 / 1_048_576.0);
112 [
113 c[0] + dir[0] / len * step,
114 c[1] + dir[1] / len * step,
115 c[2] + dir[2] / len * step,
116 ]
117}
118
119/// Centroid of a multi-arrangement sub-triangle (interner lookup).
120fn centroid_multi(arr: &MultiArrangement, tri: [Vid; 3]) -> [f64; 3] {
121 let c = [
122 point_via_interner(&arr.interner, tri[0]),
123 point_via_interner(&arr.interner, tri[1]),
124 point_via_interner(&arr.interner, tri[2]),
125 ];
126 [
127 (c[0][0] + c[1][0] + c[2][0]) / 3.0,
128 (c[0][1] + c[1][1] + c[2][1]) / 3.0,
129 (c[0][2] + c[1][2] + c[2][2]) / 3.0,
130 ]
131}
132
133fn tri_normal_multi(arr: &MultiArrangement, tri: [Vid; 3]) -> [f64; 3] {
134 let (a, b, c) = (
135 point_via_interner(&arr.interner, tri[0]),
136 point_via_interner(&arr.interner, tri[1]),
137 point_via_interner(&arr.interner, tri[2]),
138 );
139 cross3(sub_f64(b, a), sub_f64(c, a))
140}
141
142#[inline]
143fn point_via_interner(it: &Interner, v: Vid) -> [f64; 3] {
144 let pt = it.get(v);
145 if let Some(f) = super::super::fixed::point_to_f64(pt) {
146 return f;
147 }
148 let p = point_of(pt);
149 [p[0].to_f64().unwrap(), p[1].to_f64().unwrap(), p[2].to_f64().unwrap()]
150}
151
152/// Compute the boolean `op` of operand meshes `a` and `b`, materialised to f64
153/// triangles. `A−B = (A outside B) ∪ flip(B inside A)`;
154/// `A∪B = (A outside B) ∪ (B outside A)`; `A∩B = (A inside B) ∪ (B inside A)`.
155///
156/// This deliberately does NOT gate on `Arrangement::unrecovered` the way
157/// [`difference_all`] does — see [`boolean_with_conformity`], which computes
158/// the identical result and additionally surfaces that signal for callers
159/// that want it (diagnostics; issue #3353's classification-level tear).
160pub fn boolean(a: &[Tri], b: &[Tri], op: BoolOp) -> Vec<Tri> {
161 boolean_with_conformity(a, b, op).0
162}
163
164/// Like [`boolean`], but also returns whether the underlying pairwise
165/// arrangement was CONFORMING (`Arrangement::unrecovered == 0`) — the same
166/// `(result, conforming)` shape [`union_all`] already returns for the N-ary
167/// union. `boolean()` computes this exact arrangement and discards the
168/// signal on purpose (its graceful degrade when `unrecovered > 0` is
169/// intentional; see its doc comment, and contrast [`difference_all`]'s hard
170/// reject). This function changes no behaviour — it is an observation-only
171/// companion for callers (tests, diagnostics) that want to inspect the
172/// signal `boolean()` throws away, without altering `boolean()`'s own
173/// return type or any existing caller.
174pub fn boolean_with_conformity(a: &[Tri], b: &[Tri], op: BoolOp) -> (Vec<Tri>, bool) {
175 let arr = arrange(a, b);
176 let conforming = arr.unrecovered == 0;
177 (vids_to_tris(&arr, boolean_vids(&arr, a, b, op)), conforming)
178}
179
180/// `a − (∪ comps)` for PAIRWISE-DISJOINT, per-component-closed, OUTWARD-wound
181/// cutter components, computed in ONE conforming arrangement (disjoint-cutter
182/// batching).
183///
184/// Soundness: `arrange` only intersects A×B pairs, and disjoint B components
185/// have no B×B intersections to miss; classification decomposes per component
186/// (`BComponents`). The #2176 lesson is a PRECONDITION here: every component
187/// must be closed and outward-wound on its own — the caller (`mesh_bridge::
188/// subtract_many`) orients each component before concatenation, and the router
189/// only admits per-component-watertight cutters to a batch.
190///
191/// vs. sequential per-cutter subtraction this avoids re-arranging the (growing)
192/// host once per cutter — each intermediate round-trip through f32 + the snap
193/// grid re-jitters carve vertices off shared planes, so cut N+1 re-cracks what
194/// cut N reconciled (many-void walls' compounding open edges) — and is
195/// ~N× cheaper on N box cutters.
196///
197/// Returns `(difference, changed)`, the same shape as [`union_all`]'s
198/// `(union, conforming)`. `changed == false` means no cutter reaches the solid
199/// `a` bounds (every A sub-triangle kept, no B sub-triangle kept): the
200/// triangles are `a` re-tessellated, not a cut.
201pub fn difference_all(a: &[Tri], comps: &[&[Tri]]) -> Option<(Vec<Tri>, bool)> {
202 if comps.is_empty() {
203 return Some((a.to_vec(), false));
204 }
205 let b_all: Vec<Tri> = comps.iter().flat_map(|c| c.iter().copied()).collect();
206 let arr = arrange(a, &b_all);
207 // Hard conformity gate (unlike the binary `boolean`'s graceful degrade):
208 // an unrecovered constraint means some sub-triangle STRADDLES a cutter
209 // boundary and its centroid classification can silently over/under-cut
210 // (the #559171 batched-door family: the jamb never carved into the back
211 // face ⇒ −0.43 m³ and an open rim). The caller (`subtract_many`) then either
212 // accepts the volume-safe lenient batch or falls back to sequential.
213 if arr.unrecovered > 0 {
214 return None;
215 }
216 Some(classify_difference(&arr, a, comps))
217}
218
219/// Like [`difference_all`] but WITHOUT the conformity gate — returns the batched
220/// difference even when an unrecovered constraint leaves it non-conforming. The
221/// exact batched topology is cleaner than the sequential re-jitter on dense
222/// faceted-reveal walls (issue #098), but its centroid classification can
223/// over/under-cut volume, so the caller (`subtract_many`) VERIFIES the removed
224/// volume against a sequential reference before trusting it. The second
225/// element is the same `changed` bit [`difference_all`] returns.
226pub fn difference_all_lenient(a: &[Tri], comps: &[&[Tri]]) -> (Vec<Tri>, bool) {
227 let (tris, changed, _) = difference_all_lenient_with_conformity(a, comps);
228 (tris, changed)
229}
230
231/// [`difference_all_lenient`] plus whether the arrangement CONFORMED
232/// (`unrecovered == 0`). A non-conforming `changed == false` is not proof
233/// that no cutter reaches `a`: straddling sub-triangles can be misclassified
234/// (#5362 review), so a caller must not read it as "disjoint".
235pub fn difference_all_lenient_with_conformity(a: &[Tri], comps: &[&[Tri]]) -> (Vec<Tri>, bool, bool) {
236 let arr = match comps {
237 [] => return (a.to_vec(), false, true),
238 // One component (the single-cutter subtract) is already the B operand.
239 [only] => arrange(a, only),
240 _ => arrange(a, &comps.iter().flat_map(|c| c.iter().copied()).collect::<Vec<Tri>>()),
241 };
242 let (tris, changed) = classify_difference(&arr, a, comps);
243 (tris, changed, arr.unrecovered == 0)
244}
245
246/// The shared tail of [`difference_all`] and [`difference_all_lenient`]:
247/// classify `a − ∪comps` over `arr` and return `(triangles, changed)`.
248fn classify_difference(arr: &Arrangement, a: &[Tri], comps: &[&[Tri]]) -> (Vec<Tri>, bool) {
249 let bc = BComponents::new(comps);
250 let (vids, changed) = boolean_vids_components(arr, a, &bc, BoolOp::Difference);
251 (vids_to_tris(arr, vids), changed)
252}
253
254/// Classified Vid triangles back to f64 coordinates.
255fn vids_to_tris(arr: &Arrangement, vids: Vec<[Vid; 3]>) -> Vec<Tri> {
256 vids.into_iter()
257 .map(|t| [to_f64_pt(arr, t[0]), to_f64_pt(arr, t[1]), to_f64_pt(arr, t[2])])
258 .collect()
259}
260
261/// Topology fingerprint of a boolean result: each oriented Vid triangle rotated
262/// min-first (canonical start vertex, winding preserved), the list sorted,
263/// FNV-1a-hashed. Platform-stable — Vids are deterministic symbolic identities
264/// and the ray-cast classification is FMA-free f64.
265pub fn boolean_topology_hash(a: &[Tri], b: &[Tri], op: BoolOp) -> u64 {
266 let arr = arrange(a, b);
267 let mut tris: Vec<[Vid; 3]> =
268 boolean_vids(&arr, a, b, op).into_iter().map(rotate_min_first).collect();
269 tris.sort_unstable();
270 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
271 for t in tris {
272 for v in t {
273 h ^= v as u64;
274 h = h.wrapping_mul(0x0000_0100_0000_01b3);
275 }
276 }
277 h
278}
279
280/// Axis-aligned box `[lo,hi]` as 12 outward-wound triangles.
281pub fn box_mesh(lo: [f64; 3], hi: [f64; 3]) -> Vec<Tri> {
282 let p = [
283 [lo[0], lo[1], lo[2]], [hi[0], lo[1], lo[2]], [hi[0], hi[1], lo[2]], [lo[0], hi[1], lo[2]],
284 [lo[0], lo[1], hi[2]], [hi[0], lo[1], hi[2]], [hi[0], hi[1], hi[2]], [lo[0], hi[1], hi[2]],
285 ];
286 let idx = [
287 [0, 3, 2], [0, 2, 1], [4, 5, 6], [4, 6, 7],
288 [0, 4, 7], [0, 7, 3], [1, 2, 6], [1, 6, 5],
289 [0, 1, 5], [0, 5, 4], [3, 7, 6], [3, 6, 2],
290 ];
291 idx.iter().map(|f| [p[f[0]], p[f[1]], p[f[2]]]).collect()
292}
293
294/// Axis-aligned cube `[lo,hi]³`.
295pub fn cube_mesh(lo: f64, hi: f64) -> Vec<Tri> {
296 box_mesh([lo, lo, lo], [hi, hi, hi])
297}
298
299/// Cross-platform full-boolean determinism manifest: `cube[0,2]³ − cube[1,3]³`.
300pub fn boolean_manifest() -> u64 {
301 boolean_topology_hash(&cube_mesh(0.0, 2.0), &cube_mesh(1.0, 3.0), BoolOp::Difference)
302}