ifc_lite_geometry/kernel/arrangement/mod.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//! Mesh arrangement — the conforming intersection of two operand meshes.
6//!
7//! For every pair of triangles (one per operand) that cross, the intersection
8//! segment becomes a constraint for BOTH triangles (it lies on both planes), and
9//! each crossed triangle is re-triangulated with its accumulated constraints.
10//! All re-triangulations share ONE interner, so a vertex created on the
11//! intersection of A's triangle and B's triangle gets the SAME symbolic Vid in
12//! both surfaces — i.e. the two operands' meshes CONFORM along the intersection.
13//! The result is a single intersection-free complex, ready for L4 winding
14//! classification.
15//!
16//! Broadphase is a hand-rolled AABB BVH (`super::broadphase`) that filters
17//! candidate triangle pairs before the exact per-pair `tri_tri_intersection`
18//! test; that test itself resolves both the proper-crossing `Segment` case and
19//! the on-plane-vertex/edge `Touches` degeneracies (folded into `Segment`/
20//! `Chord`, see `tritri::PlaneInterval`). Coplanar overlaps are handled
21//! separately via `coplanar_clip` and accumulated alongside the segment
22//! constraints before re-triangulation.
23
24use super::coplanar::coplanar_clip;
25use super::interner::{Interner, Vid};
26use super::predicates::{cmp_lex, orient2d_any};
27use super::retriangulate::{projection_axis, triangulate, Constraint, RetriInput};
28use super::tritri::{tri_tri_intersection, TriTri};
29use super::{ImplicitPoint, Sign, Tpi};
30use std::cmp::Ordering;
31
32mod boolean;
33mod classify;
34mod coincident;
35mod ray_parity;
36#[cfg(test)]
37mod tests;
38
39pub use self::boolean::{
40 boolean, boolean_manifest, boolean_topology_hash, boolean_with_conformity, box_mesh, cube_mesh,
41 difference_all, difference_all_lenient, difference_all_lenient_with_conformity, union_all,
42};
43pub type Tri = [[f64; 3]; 3];
44
45/// Boolean operation.
46#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47pub enum BoolOp {
48 /// `A − B`.
49 Difference,
50 /// `A ∪ B`.
51 Union,
52 /// `A ∩ B`.
53 Intersection,
54}
55
56/// The conforming arrangement of two operand meshes over a shared interner.
57pub struct Arrangement {
58 pub interner: Interner,
59 /// Operand A's conforming sub-triangles (interned Vids).
60 pub tris_a: Vec<[Vid; 3]>,
61 /// Operand B's conforming sub-triangles.
62 pub tris_b: Vec<[Vid; 3]>,
63 /// Total constraint sub-segments the per-triangle re-triangulations could
64 /// not force as edges (`Mesh2d::unrecovered`). Non-zero ⇒ the operands do
65 /// not fully conform along their intersection; centroid classification of
66 /// straddling sub-triangles is unreliable. The N-ary batched difference
67 /// treats this as a hard reject; the binary boolean keeps its historical
68 /// graceful-degrade behavior.
69 pub unrecovered: usize,
70 /// Per-sub-triangle (parallel to `tris_a`) flag: this sub-triangle's PARENT
71 /// face had a COPLANAR overlap with the other operand — so a sub-triangle on
72 /// it may lie on a shared interface plane and needs the [`solid_side`] regime-2
73 /// classification (`boolean_vids`). `false` ⇒ the parent face is transversal to
74 /// the other operand, so the plain centroid ray-cast suffices (the common case;
75 /// keeps box−box etc. on the fast single-cast path).
76 pub coplanar_a: Vec<bool>,
77 /// Per-sub-triangle (parallel to `tris_b`) coplanar-parent flag.
78 pub coplanar_b: Vec<bool>,
79 /// Lazy per-Vid materialized f64 point cache. Classification
80 /// (`centroid`/`tri_normal` per sub-triangle) and the final output map all
81 /// call `to_f64_pt` on the SAME heavily-shared conforming vertices many times;
82 /// materialising the interned point (fixed-width lambda, or the BigRational
83 /// fallback) is not free, so each Vid is computed once and reused. The cached
84 /// value equals a fresh computation exactly, so the boolean output is
85 /// unchanged. Indexed by `Vid`; `None` = not yet materialised.
86 f64_cache: std::cell::RefCell<Vec<Option<[f64; 3]>>>,
87}
88
89/// A raw intersection segment on a triangle, tagged with the cutter triangle that
90/// produced it (needed to locate seg×seg crossing points as triple points).
91struct RawSeg {
92 a: ImplicitPoint,
93 b: ImplicitPoint,
94 cutter: Tri,
95}
96
97/// A segment endpoint paired with its CACHED interval lambda, so `orient2d` can run
98/// the f64-interval determinant straight from the lambda (the >95%-resolving fast
99/// tier) without re-deriving the degree-4/7 LPI/TPI lambda on every call. The raw
100/// `ImplicitPoint` is kept only for the exact-tier fallback on an interval straddle.
101type EndLam<'a> = (&'a super::interval::IvLam, &'a ImplicitPoint);
102
103/// 2-D orientation from cached interval lambdas, falling to the full exact cascade
104/// (`orient2d_any`) only when the interval determinant straddles zero. Either way
105/// the returned sign is the EXACT sign, so callers are byte-identical to recomputing.
106#[inline]
107fn orient2d_end(a: EndLam, b: EndLam, c: EndLam, axis: super::DropAxis) -> Sign {
108 super::interval::orient2d_from_lam_iv(a.0, b.0, c.0, axis)
109 .unwrap_or_else(|| orient2d_any(a.1, b.1, c.1, axis))
110}
111
112/// Do segments `(a1,b1)` and `(a2,b2)` (in `T`'s plane, projected by `axis`)
113/// properly cross at an interior point? (Shared endpoints don't count.) Lambda-
114/// cached variant of the classic four-orientation test.
115fn segments_cross(a1: EndLam, b1: EndLam, a2: EndLam, b2: EndLam, axis: super::DropAxis) -> bool {
116 let s1 = orient2d_end(a1, b1, a2, axis);
117 let s2 = orient2d_end(a1, b1, b2, axis);
118 let s3 = orient2d_end(a2, b2, a1, axis);
119 let s4 = orient2d_end(a2, b2, b1, axis);
120 s1 != Sign::Zero && s2 != Sign::Zero && s1 != s2 && s3 != Sign::Zero && s4 != Sign::Zero && s3 != s4
121}
122
123/// seg×seg pre-pass for one triangle `t`: split every pair of crossing
124/// constraint segments at their crossing point — `TPI(t.plane, cutter_i.plane,
125/// cutter_j.plane)` (a valid triple point: crossing ⇒ the two cutter lines are
126/// non-parallel in `t`'s plane ⇒ the three planes meet at a point). The result
127/// is a crossing-free constraint set the re-triangulation can handle directly.
128fn split_crossings(t: &Tri, raws: &[RawSeg]) -> Vec<Constraint> {
129 let axis = match projection_axis(t) {
130 Some((a, _)) => a,
131 None => return Vec::new(),
132 };
133 let n = raws.len();
134 // Cache each endpoint's interval lambda ONCE (Attene "Indirect Predicates" §5.4):
135 // the O(n²) crossing loop reuses every endpoint across many `orient2d` calls and
136 // would otherwise re-derive its degree-4/7 LPI/TPI lambda on each. Byte-identical
137 // — the cached lambda equals a fresh one and the exact tiers are unchanged.
138 let iv: Vec<(super::interval::IvLam, super::interval::IvLam)> = raws
139 .iter()
140 .map(|r| (super::interval::ilambda_cached(&r.a), super::interval::ilambda_cached(&r.b)))
141 .collect();
142 let mut splits: Vec<Vec<ImplicitPoint>> = vec![Vec::new(); n];
143 for k in 0..n {
144 let (ka, kb) = ((&iv[k].0, &raws[k].a), (&iv[k].1, &raws[k].b));
145 for l in (k + 1)..n {
146 let (la, lb) = ((&iv[l].0, &raws[l].a), (&iv[l].1, &raws[l].b));
147 if segments_cross(ka, kb, la, lb, axis) {
148 let x = ImplicitPoint::Tpi(Tpi {
149 planes: [*t, raws[k].cutter, raws[l].cutter],
150 });
151 splits[k].push(x.clone());
152 splits[l].push(x);
153 }
154 }
155 }
156 let mut out = Vec::new();
157 for k in 0..n {
158 let mut chain = vec![raws[k].a.clone()];
159 chain.append(&mut splits[k]);
160 chain.push(raws[k].b.clone());
161 // order along the segment (collinear ⇒ lex order = line order) + dedup coincident
162 chain.sort_by(|p, q| match cmp_lex(p, q) {
163 Sign::Negative => Ordering::Less,
164 Sign::Positive => Ordering::Greater,
165 Sign::Zero => Ordering::Equal,
166 });
167 chain.dedup_by(|p, q| cmp_lex(p, q) == Sign::Zero);
168 for w in chain.windows(2) {
169 out.push(Constraint { a: w[0].clone(), b: w[1].clone() });
170 }
171 }
172 out
173}
174
175/// Compute the conforming arrangement of operand meshes `a` and `b`.
176pub fn arrange(a: &[Tri], b: &[Tri]) -> Arrangement {
177 // 1. accumulate raw intersection segments (Segment pairs) + coplanar overlaps
178 let mut raw_a: Vec<Vec<RawSeg>> = (0..a.len()).map(|_| Vec::new()).collect();
179 let mut raw_b: Vec<Vec<RawSeg>> = (0..b.len()).map(|_| Vec::new()).collect();
180 let mut cop_a: Vec<Vec<Constraint>> = (0..a.len()).map(|_| Vec::new()).collect();
181 let mut cop_b: Vec<Vec<Constraint>> = (0..b.len()).map(|_| Vec::new()).collect();
182 let mut pt_a: Vec<Vec<ImplicitPoint>> = (0..a.len()).map(|_| Vec::new()).collect();
183 let mut pt_b: Vec<Vec<ImplicitPoint>> = (0..b.len()).map(|_| Vec::new()).collect();
184 let pairs = super::broadphase::candidate_pairs(a, b);
185 for (i, j) in pairs {
186 // Deterministic escalation guardrail (#1109): stop accumulating
187 // intersections once this boolean has blown its BigRational budget. The
188 // partial arrangement is discarded by the caller (csg.rs checks
189 // `budget::tripped()` and falls back), so an early break is safe; it just
190 // bounds the wasted work to ~one more triangle pair.
191 if super::budget::tripped() {
192 break;
193 }
194 let (ta, tb) = (&a[i], &b[j]);
195 match tri_tri_intersection(ta, tb) {
196 TriTri::Segment([s, t]) => {
197 raw_a[i].push(RawSeg { a: s.clone(), b: t.clone(), cutter: *tb });
198 raw_b[j].push(RawSeg { a: s, b: t, cutter: *ta });
199 }
200 TriTri::Coplanar => {
201 cop_a[i].extend(coplanar_clip(ta, tb).into_iter().map(|(a, b)| Constraint { a, b }));
202 cop_b[j].extend(coplanar_clip(tb, ta).into_iter().map(|(a, b)| Constraint { a, b }));
203 }
204 TriTri::Point(p) => {
205 // Tangential touch: BOTH triangles must intern the touch point
206 // as a conformity vertex. The triangle on whose EDGE the point
207 // lies otherwise never splits that edge, while its neighbor
208 // (which sees a full crossing segment ending there) does — a
209 // T-junction that opens the surface (the flush-corner-on-
210 // diagonal family; see `RetriInput::points`).
211 pt_a[i].push(p.clone());
212 pt_b[j].push(p);
213 }
214 TriTri::None => {}
215 }
216 }
217 // 2. seg×seg pre-pass on the segment constraints, then append the coplanar ones
218 let build = |tris: &[Tri], raw: &[Vec<RawSeg>], cop: &mut [Vec<Constraint>]| -> Vec<Vec<Constraint>> {
219 (0..tris.len())
220 .map(|i| {
221 let mut c = split_crossings(&tris[i], &raw[i]);
222 c.append(&mut cop[i]);
223 c
224 })
225 .collect()
226 };
227 // Per-ORIGINAL-triangle "had a coplanar overlap with the other operand" flag,
228 // captured BEFORE `build` drains the coplanar-constraint accumulators.
229 let cop_parent_a: Vec<bool> = cop_a.iter().map(|c| !c.is_empty()).collect();
230 let cop_parent_b: Vec<bool> = cop_b.iter().map(|c| !c.is_empty()).collect();
231 let ca = build(a, &raw_a, &mut cop_a);
232 let cb = build(b, &raw_b, &mut cop_b);
233 // 3. re-triangulate each operand over the SHARED interner ⇒ conforming surfaces
234 let mut interner = Interner::new();
235 let mut unrecovered = 0usize;
236 let (tris_a, coplanar_a) =
237 retriangulate_each(a, &ca, &pt_a, &cop_parent_a, &mut interner, &mut unrecovered);
238 let (tris_b, coplanar_b) =
239 retriangulate_each(b, &cb, &pt_b, &cop_parent_b, &mut interner, &mut unrecovered);
240 let n_pts = interner.len();
241 Arrangement {
242 interner,
243 tris_a,
244 tris_b,
245 coplanar_a,
246 coplanar_b,
247 unrecovered,
248 f64_cache: std::cell::RefCell::new(vec![None; n_pts]),
249 }
250}
251
252/// The conforming arrangement of `n` operand meshes over one shared interner.
253/// `subtris[k]` is mesh `k`'s conforming sub-triangles; there is no separate
254/// owner/index table — a sub-triangle's originating mesh is simply its
255/// position `k` in `subtris`.
256pub struct MultiArrangement {
257 pub interner: Interner,
258 /// `subtris[k]` = mesh `k`'s conforming sub-triangles (interned Vids).
259 pub subtris: Vec<Vec<[Vid; 3]>>,
260 /// Total constraint sub-segments the per-mesh re-triangulations could not
261 /// force as edges (summed over every mesh's `Mesh2d::unrecovered`). Non-zero
262 /// ⇒ the operands do not fully conform along their intersections, so a
263 /// straddling sub-triangle's centroid classification in [`union_all`] is
264 /// unreliable (the union can silently tear). Mirrors [`Arrangement::unrecovered`]
265 /// so [`union_all`] can SIGNAL the same non-conformity condition
266 /// [`difference_all`] hard-rejects (it was previously discarded into a `_`).
267 pub unrecovered: usize,
268}
269
270/// Conforming arrangement of `meshes` (N operands) over ONE interner.
271///
272/// Every ORDERED pair of distinct meshes is intersected (segment + coplanar)
273/// exactly as the binary [`arrange`] does, and the accumulated constraints
274/// re-triangulate each mesh over the shared interner — so a vertex created on a
275/// shared edge/seam interns to the SAME Vid in every mesh that touches it (full
276/// N-way conformity). This is what lets [`union_all`] classify each sub-triangle
277/// against all OTHER solids WITHOUT ever re-meshing an intermediate union (the
278/// pairwise-accumulation step that compounds coplanar T-junctions).
279pub fn arrange_many(meshes: &[&[Tri]]) -> MultiArrangement {
280 let n = meshes.len();
281 // per-mesh, per-triangle constraint accumulators
282 let mut raw: Vec<Vec<Vec<RawSeg>>> =
283 meshes.iter().map(|m| (0..m.len()).map(|_| Vec::new()).collect()).collect();
284 let mut cop: Vec<Vec<Vec<Constraint>>> =
285 meshes.iter().map(|m| (0..m.len()).map(|_| Vec::new()).collect()).collect();
286 let mut pts: Vec<Vec<Vec<ImplicitPoint>>> =
287 meshes.iter().map(|m| (0..m.len()).map(|_| Vec::new()).collect()).collect();
288 // intersect every unordered mesh pair (i<j); push constraints to BOTH.
289 for i in 0..n {
290 if super::budget::tripped() {
291 break;
292 }
293 for j in (i + 1)..n {
294 let pairs = super::broadphase::candidate_pairs(meshes[i], meshes[j]);
295 for (ti, tj) in pairs {
296 // Escalation guardrail (#1109) — see `arrange`.
297 if super::budget::tripped() {
298 break;
299 }
300 let (ta, tb) = (&meshes[i][ti], &meshes[j][tj]);
301 match tri_tri_intersection(ta, tb) {
302 TriTri::Segment([s, t]) => {
303 raw[i][ti].push(RawSeg { a: s.clone(), b: t.clone(), cutter: *tb });
304 raw[j][tj].push(RawSeg { a: s, b: t, cutter: *ta });
305 }
306 TriTri::Coplanar => {
307 cop[i][ti].extend(
308 coplanar_clip(ta, tb).into_iter().map(|(a, b)| Constraint { a, b }),
309 );
310 cop[j][tj].extend(
311 coplanar_clip(tb, ta).into_iter().map(|(a, b)| Constraint { a, b }),
312 );
313 }
314 TriTri::Point(p) => {
315 // tangential touch — conformity vertex on both sides
316 pts[i][ti].push(p.clone());
317 pts[j][tj].push(p);
318 }
319 TriTri::None => {}
320 }
321 }
322 }
323 }
324 let mut interner = Interner::new();
325 let mut subtris = Vec::with_capacity(n);
326 // Accumulate unrecovered constraints across EVERY mesh's re-triangulation
327 // (mirrors the binary `arrange`, which sums over both operands). `union_all`
328 // surfaces any non-zero total as its non-conformity signal, so it must not be
329 // discarded here (it previously was, into a `_unrecovered`).
330 let mut unrecovered = 0usize;
331 for k in 0..n {
332 let cop_parent: Vec<bool> = cop[k].iter().map(|c| !c.is_empty()).collect();
333 let cons: Vec<Vec<Constraint>> = (0..meshes[k].len())
334 .map(|t| {
335 let mut c = split_crossings(&meshes[k][t], &raw[k][t]);
336 c.append(&mut cop[k][t]);
337 c
338 })
339 .collect();
340 // `union_all` classifies every sub-triangle against each other mesh via the
341 // off-plane `solid_side` probe, so it needs no per-sub coplanar flag here.
342 let (tris, _coplanar) =
343 retriangulate_each(meshes[k], &cons, &pts[k], &cop_parent, &mut interner, &mut unrecovered);
344 subtris.push(tris);
345 }
346 MultiArrangement { interner, subtris, unrecovered }
347}
348
349/// Re-triangulate each original triangle over the shared interner. Returns the
350/// conforming sub-triangles AND a parallel `coplanar` flag (each sub-triangle
351/// inherits its parent original triangle's `cop_parent[i]` — "had a coplanar
352/// overlap with the other operand"). The flag drives the [`solid_side`] regime-2
353/// classification in `boolean_vids` only where it can matter.
354fn retriangulate_each(
355 tris: &[Tri],
356 cons: &[Vec<Constraint>],
357 pts: &[Vec<ImplicitPoint>],
358 cop_parent: &[bool],
359 it: &mut Interner,
360 unrecovered: &mut usize,
361) -> (Vec<[Vid; 3]>, Vec<bool>) {
362 let mut out = Vec::new();
363 let mut coplanar = Vec::new();
364 for (i, t) in tris.iter().enumerate() {
365 // Escalation guardrail (#1109): the seam retriangulation runs exact
366 // orient2d per ear and is the other heavy escalation site. Stop once the
367 // budget is blown — the caller discards the partial arrangement.
368 if super::budget::tripped() {
369 break;
370 }
371 let parent_cop = cop_parent.get(i).copied().unwrap_or(false);
372 let passthrough = |it: &mut Interner| {
373 [
374 it.intern(ImplicitPoint::Explicit(t[0])),
375 it.intern(ImplicitPoint::Explicit(t[1])),
376 it.intern(ImplicitPoint::Explicit(t[2])),
377 ]
378 };
379 let before = out.len();
380 let tri_pts = pts.get(i).cloned().unwrap_or_default();
381 if cons[i].is_empty() && tri_pts.is_empty() {
382 out.push(passthrough(it));
383 } else if let Some(mesh) = triangulate(
384 &RetriInput { tri: *t, constraints: cons[i].clone(), points: tri_pts },
385 it,
386 ) {
387 *unrecovered += mesh.unrecovered;
388 out.extend(mesh.tris);
389 } else {
390 out.push(passthrough(it)); // degenerate triangle — pass through
391 }
392 coplanar.resize(out.len(), parent_cop);
393 debug_assert!(coplanar.len() == out.len() && before <= out.len());
394 }
395 (out, coplanar)
396}