Skip to main content

manifold_rust/robust/
intersection_graph.rs

1// robust/intersection_graph.rs — From two triangle soups to classified-ready
2// pieces (paper §6).
3//
4// Pipeline stage between the narrow phase (robust/tri_tri.rs) and
5// classification (robust/cells.rs):
6//   1. AABB broad phase over the P×Q triangle pairs, exact narrow phase.
7//   2. Distribute each pair's intersection primitives to both triangles.
8//   3. For coplanar overlaps, cross-copy each side's other primitives
9//      (clipped to the overlap region) so both sides subdivide the shared
10//      region identically.
11//   4. Global registries force a common subdivision of (a) every original
12//      mesh edge and (b) every intersection segment, by feeding all split
13//      points to every arrangement that sees the same geometry — exact-key
14//      edge matching downstream depends on this.
15//   5. Build the per-triangle arrangements (robust/arrangement.rs +
16//      robust/cdt.rs) and emit `Piece`s: outward-oriented sub-triangles (or
17//      whole untouched triangles) tagged with their origin.
18//
19// Everything is exact; broad-phase boxes are conservative f64.
20//
21// This file holds the build pipeline only. Its vocabulary types live in
22// robust/graph_types.rs (edge keys, `VertInterner`, `Piece`,
23// `IntersectionGraph`), its geometric helpers in robust/graph_geom.rs, and
24// the same-mesh narrow phase in robust/graph_self_cut.rs — all re-exported
25// here so callers keep using `intersection_graph::` paths.
26
27use std::collections::BTreeSet;
28
29// Fx hashing instead of SipHash. Every map/set below is probe-only or has an
30// order-invariant consumer (documented per site); the hasher is unseeded, so
31// even iteration order is stable across runs — output cannot depend on it.
32use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
33
34use crate::linalg::Vec3;
35use crate::types::Box;
36
37use super::arrangement::{self, ArrangementInput};
38use super::exact::rational::{r3_eq, R3};
39use super::tri_tri::{tri_tri_intersect, TriTriIsect};
40
41use super::graph_geom::{
42    approx3, box3_contains, clip_segment_to_polygon, point_in_polygon_coplanar, point_on_segment_f,
43    seg_box3,
44};
45use super::graph_types::{bit_edge_key, geo_edge_key, BitEdgeKey, GeoEdgeKey, PointTable};
46
47// `tri_box` / `is_degenerate` / `real_self_contact` / `SelfCutStats` stay
48// crate-internal (robust/soup.rs reaches them through this path).
49pub(super) use super::graph_geom::{is_degenerate, tri_box};
50pub(super) use super::graph_self_cut::{real_self_contact, SelfCutStats};
51pub use super::graph_types::{edge_key, EdgeKey, IntersectionGraph, Piece, VertInterner};
52
53/// A pair's primitives after distribution: segments (including coplanar
54/// boundary edges) and isolated points.
55#[derive(Clone, Debug, Default)]
56struct TriPrims {
57    points: Vec<(R3, usize)>,
58    segments: Vec<(R3, R3, usize)>,
59}
60
61/// Build the intersection graph for soups `p` and `q` (each triangle wound
62/// outward; degenerate triangles are dropped here, paper §5).
63pub fn build_graph(p: &[[Vec3; 3]], q: &[[Vec3; 3]]) -> IntersectionGraph {
64    build_graph_with_token(p, q, None).expect("uncancellable build_graph cannot cancel")
65}
66
67/// [`build_graph`] with cooperative cancellation. Returns `None` when the
68/// token fires. Checks run per triangle in every phase and inside the
69/// arrangement sweeps — heavily self-intersecting inputs spend minutes in
70/// per-triangle quadratic loops, and a cancel that only top-level phases
71/// notice can overshoot its deadline by that much (Thingi10K #42211 ran
72/// 565 s past a 60 s cancel before this plumbing).
73pub fn build_graph_with_token(
74    p: &[[Vec3; 3]],
75    q: &[[Vec3; 3]],
76    token: Option<&crate::cancel::CancelToken>,
77) -> Option<IntersectionGraph> {
78    build_graph_with_progress(p, q, token, None)
79}
80
81/// [`build_graph_with_token`] that also reports its five phases to
82/// `progress` (see [`crate::progress`]). `None` is exactly
83/// [`build_graph_with_token`]: no counter is touched and no branch is taken
84/// inside any inner loop.
85pub fn build_graph_with_progress(
86    p: &[[Vec3; 3]],
87    q: &[[Vec3; 3]],
88    token: Option<&crate::cancel::CancelToken>,
89    progress: Option<&crate::progress::ProgressReporter>,
90) -> Option<IntersectionGraph> {
91    use crate::progress::{begin_phase, maybe_par_map_ct_progress, Phase};
92    let cancelled = || crate::cancel::is_cancelled(token);
93    let t_all = crate::timing::start();
94    let meshes: [&[[Vec3; 3]]; 2] = [p, q];
95    let live: [Vec<bool>; 2] = [
96        p.iter().map(|t| !is_degenerate(t)).collect(),
97        q.iter().map(|t| !is_degenerate(t)).collect(),
98    ];
99
100    // 1. Broad + narrow phase. The broad phase is a BVH (the same Collider
101    // the exact engine uses) over Q's triangle boxes, queried with each P
102    // triangle's box — O((|P|+|Q|)·log|Q|) instead of the all-pairs box
103    // sweep. Candidates are re-sorted to ascending qi per pi, so the pair
104    // provenance ids match the exhaustive loop exactly (only genuinely
105    // intersecting pairs consume an id, and the exact narrow phase decides
106    // those identically regardless of broad-phase method).
107    let p_boxes: Vec<Box> = p.iter().map(tri_box).collect();
108    let q_boxes: Vec<Box> = q.iter().map(tri_box).collect();
109
110    let scene_box = q_boxes
111        .iter()
112        .enumerate()
113        .filter(|(qi, _)| live[1][*qi])
114        .fold(Box::new(), |acc, (_, b)| acc.union_box(b));
115    let mut q_order: Vec<usize> = (0..q.len()).filter(|&qi| live[1][qi]).collect();
116    q_order.sort_by_key(|&qi| crate::sort::morton_code(q_boxes[qi].center(), &scene_box));
117    let leaf_boxes: Vec<Box> = q_order.iter().map(|&qi| q_boxes[qi]).collect();
118    let leaf_morton: Vec<u32> = q_order
119        .iter()
120        .map(|&qi| crate::sort::morton_code(q_boxes[qi].center(), &scene_box))
121        .collect();
122    let collider = crate::collider::Collider::new(leaf_boxes, leaf_morton);
123
124    // Per-(mesh, tri) primitive lists; provenance = pair index.
125    let mut prims: [Vec<TriPrims>; 2] = [
126        vec![TriPrims::default(); p.len()],
127        vec![TriPrims::default(); q.len()],
128    ];
129    // Coplanar overlap regions per pair, for the cross-copy step:
130    // (p_tri, q_tri, polygon).
131    let mut coplanar_regions: Vec<(usize, usize, Vec<R3>)> = Vec::new();
132    let mut any_intersections = false;
133    let mut pair_count = 0usize;
134
135    let mut candidates_q: Vec<usize> = Vec::new();
136    begin_phase(progress, Phase::NarrowPhase, p.len() as u64);
137    for (pi, pt) in p.iter().enumerate() {
138        if cancelled() {
139            return None;
140        }
141        if let Some(pr) = progress {
142            pr.advance(1);
143        }
144        if !live[0][pi] {
145            continue;
146        }
147        candidates_q.clear();
148        collider.collisions_one(&p_boxes[pi], pi, |_, leaf| {
149            candidates_q.push(q_order[leaf]);
150        });
151        candidates_q.sort_unstable();
152        for &qi in &candidates_q {
153            let qt = &q[qi];
154            if !p_boxes[pi].does_overlap_box(&q_boxes[qi]) {
155                continue;
156            }
157            let isect = tri_tri_intersect(*pt, *qt);
158            let pair = pair_count;
159            match isect {
160                TriTriIsect::None => continue,
161                TriTriIsect::Point(x) => {
162                    prims[0][pi].points.push((x.clone(), pair));
163                    prims[1][qi].points.push((x, pair));
164                }
165                TriTriIsect::Segment(x, y) => {
166                    prims[0][pi].segments.push((x.clone(), y.clone(), pair));
167                    prims[1][qi].segments.push((x, y, pair));
168                }
169                TriTriIsect::Coplanar { polygon, .. } => {
170                    for i in 0..polygon.len() {
171                        let a = polygon[i].clone();
172                        let b = polygon[(i + 1) % polygon.len()].clone();
173                        prims[0][pi].segments.push((a.clone(), b.clone(), pair));
174                        prims[1][qi].segments.push((a, b, pair));
175                    }
176                    coplanar_regions.push((pi, qi, polygon));
177                }
178            }
179            any_intersections = true;
180            pair_count += 1;
181        }
182    }
183
184    crate::timing::print("robust: pair narrow phase", t_all);
185    let t_self = crate::timing::start();
186
187    // 2b. Self-intersections: cut each mesh along its own P×P / Q×Q contact
188    // segments (beyond ordinary adjacency). Without these cuts a piece could
189    // straddle a fold of a self-overlapping operand, making "is this piece
190    // an interior wall of its own solid" ill-defined; with them, both
191    // winding numbers the classification needs are constant per flood-fill
192    // component (robust/propagate.rs never crosses constraint edges).
193    // Broad phase: per-mesh BVH, same approach as the cross-mesh loop above
194    // (candidates re-sorted so provenance ids stay deterministic).
195    begin_phase(
196        progress,
197        Phase::SelfIntersections,
198        (p.len() + q.len()) as u64,
199    );
200    for m in 0..2 {
201        let (tris, boxes) = if m == 0 {
202            (p, &p_boxes)
203        } else {
204            (q, &q_boxes)
205        };
206        let self_scene = boxes
207            .iter()
208            .enumerate()
209            .filter(|(i, _)| live[m][*i])
210            .fold(Box::new(), |acc, (_, b)| acc.union_box(b));
211        let mut order: Vec<usize> = (0..tris.len()).filter(|&i| live[m][i]).collect();
212        order.sort_by_key(|&i| crate::sort::morton_code(boxes[i].center(), &self_scene));
213        let self_collider = crate::collider::Collider::new(
214            order.iter().map(|&i| boxes[i]).collect(),
215            order
216                .iter()
217                .map(|&i| crate::sort::morton_code(boxes[i].center(), &self_scene))
218                .collect(),
219        );
220        // The exact narrow phase per triangle is pure; workers return each
221        // triangle's (j, segments) contacts and per-worker stats, and the
222        // sequential merge assigns provenance pair ids in (i, j) order —
223        // identical to the sequential sweep.
224        let mut n_pairs = 0usize;
225        let mut n_cut = 0usize;
226        let mut stats = SelfCutStats::default();
227        let contact_results = maybe_par_map_ct_progress(tris.len(), 64, token, progress, |i| {
228            let mut local = SelfCutStats::default();
229            let mut contacts: Vec<(usize, Vec<(R3, R3)>)> = Vec::new();
230            let mut local_pairs = 0usize;
231            if live[m][i] {
232                let mut cands: Vec<usize> = Vec::new();
233                self_collider.collisions_one(&boxes[i], i, |_, leaf| {
234                    cands.push(order[leaf]);
235                });
236                cands.sort_unstable();
237                for &j in &cands {
238                    if j <= i || !boxes[i].does_overlap_box(&boxes[j]) {
239                        continue;
240                    }
241                    local_pairs += 1;
242                    if let Some(segs) = real_self_contact(tris[i], tris[j], &mut local) {
243                        contacts.push((j, segs));
244                    }
245                }
246            }
247            (contacts, local, local_pairs)
248        })?;
249        for (i, (contacts, local, local_pairs)) in contact_results.into_iter().enumerate() {
250            n_pairs += local_pairs;
251            stats.add(&local);
252            for (j, segs) in contacts {
253                n_cut += 1;
254                for (x, y) in segs {
255                    let pair = pair_count;
256                    prims[m][i].segments.push((x.clone(), y.clone(), pair));
257                    prims[m][j].segments.push((x, y, pair));
258                    pair_count += 1;
259                }
260            }
261        }
262        crate::timing::print_count(
263            &format!("robust: self-cut mesh {m}: {n_pairs} box pairs, {n_cut} cutting"),
264        );
265        crate::timing::print_count(&format!(
266            "robust: self-cut mesh {m} tri_tri exits: {}",
267            super::tri_tri::stats::snapshot_and_reset()
268        ));
269        crate::timing::print_count(&format!(
270            "robust: self-cut mesh {m} paths: identical {}, edge-benign {}, vert-benign {}, \
271             full {} ({:.3}s: none {}, point {}, seg-benign {})",
272            stats.identical,
273            stats.edge_benign,
274            stats.vert_benign,
275            stats.full,
276            stats.full_secs,
277            stats.full_none,
278            stats.full_point,
279            stats.full_seg_benign,
280        ));
281    }
282
283    crate::timing::print("robust: self-intersection cuts", t_self);
284    let t_cross = crate::timing::start();
285
286    // 3. Cross-copy primitives through coplanar overlap regions so both
287    // sides see identical geometry inside the shared area. Clip against the
288    // region to avoid dragging unrelated geometry across.
289    for (pi, qi, poly) in &coplanar_regions {
290        if cancelled() {
291            return None;
292        }
293        let from_p: TriPrims = prims[0][*pi].clone();
294        let from_q: TriPrims = prims[1][*qi].clone();
295        let copy = |src: &TriPrims, dst: &mut TriPrims| {
296            for (a, b, prov) in &src.segments {
297                if let Some((ca, cb)) = clip_segment_to_polygon(a, b, poly) {
298                    if !dst
299                        .segments
300                        .iter()
301                        .any(|(x, y, pv)| pv == prov && ((x, y) == (&ca, &cb) || (x, y) == (&cb, &ca)))
302                    {
303                        dst.segments.push((ca, cb, *prov));
304                    }
305                }
306            }
307            for (pt, prov) in &src.points {
308                if clip_segment_to_polygon(pt, pt, poly).is_some()
309                    || point_in_polygon_coplanar(pt, poly)
310                {
311                    if !dst.points.iter().any(|(x, pv)| pv == prov && x == pt) {
312                        dst.points.push((pt.clone(), *prov));
313                    }
314                }
315            }
316        };
317        copy(&from_p, &mut prims[1][*qi]);
318        copy(&from_q, &mut prims[0][*pi]);
319    }
320
321    crate::timing::print("robust: coplanar cross-copy", t_cross);
322    let t_cand = crate::timing::start();
323
324    // 4a. Candidate points per intersected triangle. Pure per triangle, so
325    // the map parallelizes under the bit-identical rule: results land in
326    // worklist order regardless of schedule.
327    //
328    // Candidates are interned into a build-local [`PointTable`] as they land
329    // and kept as `u32` ids from here on. Everything downstream of this point
330    // (the two registry sweeps, their hit lists, their dedup sets and their
331    // keys) then moves ids instead of rational triples, which is what makes
332    // million-split meshes fit in memory — see PointTable's own comment.
333    let mut candidates: [Vec<Option<Vec<u32>>>; 2] = [vec![None; p.len()], vec![None; q.len()]];
334    let mut ptab = PointTable::default();
335    let cand_work: Vec<(usize, usize)> = (0..2)
336        .flat_map(|m| (0..meshes[m].len()).map(move |ti| (m, ti)))
337        .filter(|&(m, ti)| {
338            let pr = &prims[m][ti];
339            !pr.points.is_empty() || !pr.segments.is_empty()
340        })
341        .collect();
342    begin_phase(progress, Phase::CandidatePoints, cand_work.len() as u64);
343    // Swept in chunks: a parallel map over the whole worklist would hold
344    // every triangle's rational candidate list alive at once (12.6 M points
345    // on Thingi10K #252784), whereas interning after each chunk keeps only
346    // one copy per distinct point. Chunk boundaries are pure batching — the
347    // closure, the result order and the interning order are unchanged, so
348    // the ids (and everything downstream) are identical to the one-shot map.
349    const CAND_CHUNK: usize = 1 << 16;
350    let mut n_cand_total = 0usize;
351    let mut base = 0usize;
352    while base < cand_work.len() {
353        let len = CAND_CHUNK.min(cand_work.len() - base);
354        let cand_results = maybe_par_map_ct_progress(len, 16, token, progress, |i| {
355            let (m, ti) = cand_work[base + i];
356            let pr = &prims[m][ti];
357            let input = ArrangementInput {
358                points: pr.points.clone(),
359                segments: pr.segments.clone(),
360            };
361            arrangement::candidate_points(meshes[m][ti], &input, token)
362        })?;
363        for (k, (&(m, ti), cands)) in cand_work[base..base + len]
364            .iter()
365            .zip(cand_results)
366            .enumerate()
367        {
368            if k % 1024 == 0 && cancelled() {
369                return None;
370            }
371            let cands = cands?;
372            n_cand_total += cands.len();
373            candidates[m][ti] = Some(cands.iter().map(|pt| ptab.intern(pt)).collect());
374        }
375        base += len;
376    }
377
378    // Intersection-segment endpoints share the id space, so the segment
379    // registry keys on `(u32, u32)` too. Flat per-mesh arrays (offsets +
380    // endpoint-id pairs) mirror `prims[m][ti].segments` exactly; phases 4c
381    // and 5 read the ids instead of rebuilding rational keys per probe.
382    let mut seg_off: [Vec<usize>; 2] = [Vec::new(), Vec::new()];
383    let mut seg_ends: [Vec<(u32, u32)>; 2] = [Vec::new(), Vec::new()];
384    for m in 0..2 {
385        seg_off[m].reserve(meshes[m].len() + 1);
386        seg_off[m].push(0);
387        for ti in 0..meshes[m].len() {
388            if ti % 1024 == 0 && cancelled() {
389                return None;
390            }
391            for (a, b, _) in &prims[m][ti].segments {
392                let ia = ptab.intern(a);
393                let ib = ptab.intern(b);
394                seg_ends[m].push((ia, ib));
395            }
396            seg_off[m].push(seg_ends[m].len());
397        }
398    }
399
400    // One exact point and one rounded approximation per id, shared by every
401    // triangle that sees it (both registry sweeps used to re-round every
402    // candidate per triangle).
403    let pts: Vec<&R3> = ptab.resolve();
404    let pts_a: Vec<[f64; 3]> = pts.iter().map(|p| approx3(p)).collect();
405
406    crate::timing::print("robust: candidate points", t_cand);
407    crate::timing::print_count(&format!(
408        "robust: candidate points: {n_cand_total} total, {} interned (incl. segment endpoints), \
409         {} segment instances",
410        ptab.len(),
411        seg_ends[0].len() + seg_ends[1].len()
412    ));
413
414    let t_reg = crate::timing::start();
415
416    // 4b. Original-edge registry: split points on each mesh edge (geometric
417    // identity — soups have no reliable connectivity). Bit-keyed: original
418    // edges join exact f64 vertices.
419    // Both registry sweeps are pure per triangle; workers collect local
420    // (key, point) hits and the single-threaded merge inserts them in
421    // worklist order. Registry values are sets, so content is
422    // order-independent and the merge order only preserves determinism of
423    // allocation, not meaning.
424    let reg_work: Vec<(usize, usize)> = (0..2)
425        .flat_map(|m| (0..meshes[m].len()).map(move |ti| (m, ti)))
426        .filter(|&(m, ti)| candidates[m][ti].is_some())
427        .collect();
428    // Two sweeps (original edges, then intersection segments) over the same
429    // worklist, so the phase total counts it twice.
430    begin_phase(progress, Phase::Registries, 2 * reg_work.len() as u64);
431
432    // Registry values are (ids, seen) rather than BTreeSet: dedup is a u32
433    // hash-set probe, so the sequential merge never pays ordered rational
434    // comparisons, and the consuming `extra` sets see each point exactly
435    // once — the same content BTreeSet gave. Dedup by id is dedup by exact
436    // value: `PointTable` is injective on the same equality (`r3_eq` on
437    // canonical rationals) the `R3Key` sets used.
438    // Order invariance: the registry is only ever `get`/`entry`-probed, and
439    // its points land in a `BTreeSet<R3>` (`extra`) below, which sorts.
440    let mut edge_registry: [HashMap<BitEdgeKey, (Vec<u32>, HashSet<u32>)>; 2] =
441        [HashMap::default(), HashMap::default()];
442    let edge_hits = maybe_par_map_ct_progress(reg_work.len(), 16, token, progress, |i| {
443        let (m, ti) = reg_work[i];
444        let cands = candidates[m][ti].as_ref().expect("filtered to Some");
445        let t = meshes[m][ti];
446        let corners = [
447            R3::from_vec3(t[0]),
448            R3::from_vec3(t[1]),
449            R3::from_vec3(t[2]),
450        ];
451        let ca: [[f64; 3]; 3] = [
452            [t[0].x, t[0].y, t[0].z],
453            [t[1].x, t[1].y, t[1].z],
454            [t[2].x, t[2].y, t[2].z],
455        ];
456        let mut hits: Vec<(BitEdgeKey, u32)> = Vec::new();
457        for e in 0..3 {
458            let a = &corners[e];
459            let b = &corners[(e + 1) % 3];
460            let key = bit_edge_key(t[e], t[(e + 1) % 3]);
461            let sbox = seg_box3(ca[e], ca[(e + 1) % 3]);
462            for &id in cands.iter() {
463                let (pt, pt_a) = (pts[id as usize], pts_a[id as usize]);
464                // A point on the edge lies inside its inflated box; the
465                // reject skips the exact comparisons for everything else.
466                if !box3_contains(&sbox, pt_a) {
467                    continue;
468                }
469                if !r3_eq(pt, a)
470                    && !r3_eq(pt, b)
471                    && point_on_segment_f(pt_a, pt, ca[e], a, ca[(e + 1) % 3], b)
472                {
473                    hits.push((key, id));
474                }
475            }
476        }
477        hits
478    })?;
479    for (k, (&(m, _), hits)) in reg_work.iter().zip(&edge_hits).enumerate() {
480        if k % 1024 == 0 && cancelled() {
481            return None;
482        }
483        for &(key, id) in hits {
484            let e = edge_registry[m].entry(key).or_default();
485            if e.1.insert(id) {
486                e.0.push(id);
487            }
488        }
489    }
490    let n_edge_hits: usize = edge_hits.iter().map(|h| h.len()).sum();
491    drop(edge_hits);
492
493    // 4c. Intersection-segment registry: for every pair segment, gather the
494    // split points both sides know about.
495    // Keyed on the segment's two endpoint ids: the map is only ever probed
496    // (entry/get), never iterated, and rational keys cost both the compare
497    // (BTreeMap) or hash (R3Key) per probe and a cloned rational triple per
498    // segment instance.
499    // Same order-invariance argument as `edge_registry`: probe-only, and the
500    // points it hands out are re-sorted through `extra: BTreeSet<R3>`.
501    let mut seg_splits: HashMap<GeoEdgeKey, (Vec<u32>, HashSet<u32>)> = HashMap::default();
502    let split_hits = maybe_par_map_ct_progress(reg_work.len(), 16, token, progress, |i| {
503        let (m, ti) = reg_work[i];
504        let cands = candidates[m][ti].as_ref().expect("filtered to Some");
505        let mut hits: Vec<(GeoEdgeKey, u32)> = Vec::new();
506        for &(ia, ib) in &seg_ends[m][seg_off[m][ti]..seg_off[m][ti + 1]] {
507            let key = geo_edge_key(ia, ib);
508            let (a, b) = (pts[ia as usize], pts[ib as usize]);
509            let (aa, ba) = (pts_a[ia as usize], pts_a[ib as usize]);
510            let sbox = seg_box3(aa, ba);
511            for &id in cands.iter() {
512                let (pt, pt_a) = (pts[id as usize], pts_a[id as usize]);
513                if !box3_contains(&sbox, pt_a) {
514                    continue;
515                }
516                // Endpoint rejection by id: ids are injective on exact
517                // value, so this is exactly the `r3_eq` test it replaces.
518                if id != ia && id != ib && point_on_segment_f(pt_a, pt, aa, a, ba, b) {
519                    hits.push((key, id));
520                }
521            }
522        }
523        hits
524    })?;
525    for (k, hits) in split_hits.iter().enumerate() {
526        if k % 1024 == 0 && cancelled() {
527            return None;
528        }
529        for &(key, id) in hits {
530            let e = seg_splits.entry(key).or_default();
531            if e.1.insert(id) {
532                e.0.push(id);
533            }
534        }
535    }
536    let n_split_hits: usize = split_hits.iter().map(|h| h.len()).sum();
537    drop(split_hits);
538
539    // The dedup sets have done their job; the arrangement phase below reads
540    // only the id lists. Releasing them (and the candidate lists, which no
541    // later phase touches) before phase 5 keeps the two peaks from stacking.
542    // Each step frees a set and reallocates a vector; millions of registry
543    // entries make even that a measurable stretch of uninterruptible work.
544    for m in 0..2 {
545        for (k, v) in edge_registry[m].values_mut().enumerate() {
546            if k % 4096 == 0 && cancelled() {
547                return None;
548            }
549            v.1 = HashSet::default();
550            v.0.shrink_to_fit();
551        }
552    }
553    for (k, v) in seg_splits.values_mut().enumerate() {
554        if k % 4096 == 0 && cancelled() {
555            return None;
556        }
557        v.1 = HashSet::default();
558        v.0.shrink_to_fit();
559    }
560    drop(candidates);
561    drop(pts_a);
562
563    crate::timing::print("robust: split registries", t_reg);
564    crate::timing::print_count(&format!(
565        "robust: split registries: {n_edge_hits} edge hits over {} edges, \
566         {n_split_hits} segment hits over {} segments",
567        edge_registry[0].len() + edge_registry[1].len(),
568        seg_splits.len()
569    ));
570    let t_arr = crate::timing::start();
571
572    // 5. Build arrangements and emit pieces. The per-triangle arrangement
573    // (registry probes, CDT, crossings) is pure and runs in parallel; the
574    // interner is order-sensitive, so interning and piece emission replay
575    // the results strictly in worklist order — outputs are bit-identical to
576    // the sequential build.
577    enum TriResult {
578        /// Untouched triangle → whole piece, interned by f64 bits.
579        Untouched,
580        Arranged(arrangement::Arrangement),
581    }
582    let arr_work: Vec<(usize, usize)> = (0..2)
583        .flat_map(|m| (0..meshes[m].len()).map(move |ti| (m, ti)))
584        .filter(|&(m, ti)| live[m][ti])
585        .collect();
586    begin_phase(progress, Phase::Arrangements, arr_work.len() as u64);
587    let arr_results = maybe_par_map_ct_progress(arr_work.len(), 16, token, progress, |i| {
588        let (m, ti) = arr_work[i];
589        let t = meshes[m][ti];
590        let pr = &prims[m][ti];
591        // Boundary split points for this triangle (bit-keyed: uncut
592        // triangles probe with zero rational work).
593        // Registry ids resolve back to points only here, one clone per point
594        // that actually reaches this triangle's arrangement — the set itself
595        // stays `BTreeSet<R3>` so the arrangement's input order is untouched.
596        let mut extra: BTreeSet<R3> = BTreeSet::new();
597        for e in 0..3 {
598            if let Some(set) = edge_registry[m].get(&bit_edge_key(t[e], t[(e + 1) % 3])) {
599                extra.extend(set.0.iter().map(|&id| pts[id as usize].clone()));
600            }
601        }
602        // Split points along this triangle's intersection segments
603        // discovered by the other side.
604        for &(ia, ib) in &seg_ends[m][seg_off[m][ti]..seg_off[m][ti + 1]] {
605            if let Some(set) = seg_splits.get(&geo_edge_key(ia, ib)) {
606                extra.extend(set.0.iter().map(|&id| pts[id as usize].clone()));
607            }
608        }
609
610        if pr.points.is_empty() && pr.segments.is_empty() && extra.is_empty() {
611            return Some(TriResult::Untouched);
612        }
613        let mut input = ArrangementInput {
614            points: pr.points.clone(),
615            segments: pr.segments.clone(),
616        };
617        for pt in extra {
618            input.points.push((pt, usize::MAX));
619        }
620        arrangement::build(t, &input, token).map(TriResult::Arranged)
621    })?;
622
623    let mut pieces: Vec<Piece> = Vec::new();
624    // Membership-only set (never iterated by this crate); order-invariant.
625    let mut isect_edges: HashSet<EdgeKey> = HashSet::default();
626    let mut interner = VertInterner::default();
627    for (k, (&(m, ti), result)) in arr_work.iter().zip(arr_results).enumerate() {
628        if k % 1024 == 0 && cancelled() {
629            return None;
630        }
631        let t = meshes[m][ti];
632        match result? {
633            TriResult::Untouched => {
634                pieces.push(Piece {
635                    mesh: m as u8,
636                    tri: ti,
637                    vi: [
638                        interner.intern_f64(t[0]),
639                        interner.intern_f64(t[1]),
640                        interner.intern_f64(t[2]),
641                    ],
642                });
643            }
644            TriResult::Arranged(arr) => {
645                // Intern each arrangement point once; sub-triangles and
646                // constraint edges then only shuffle ids.
647                let ids: Vec<u32> = arr.points3.iter().map(|p| interner.intern(p)).collect();
648                for (u, w) in arr.constraints.keys() {
649                    isect_edges.insert(edge_key(ids[*u], ids[*w]));
650                }
651                for st in &arr.tris {
652                    let (a, b, c) = (st[0], st[1], st[2]);
653                    let vi = if arr.flipped {
654                        [ids[a], ids[c], ids[b]]
655                    } else {
656                        [ids[a], ids[b], ids[c]]
657                    };
658                    pieces.push(Piece {
659                        mesh: m as u8,
660                        tri: ti,
661                        vi,
662                    });
663                }
664            }
665        }
666    }
667
668    crate::timing::print("robust: arrangements", t_arr);
669    crate::timing::print_count(&format!(
670        "robust: arrangement phases: {}",
671        arrangement::stats::snapshot_and_reset()
672    ));
673
674    Some(IntersectionGraph {
675        pieces,
676        verts: interner.verts,
677        verts_f64: interner.verts_f64,
678        isect_edges,
679        any_intersections,
680    })
681}