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}