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, R3Key};
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};
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    let mut candidates: [Vec<Option<Vec<R3>>>; 2] = [
328        vec![None; p.len()],
329        vec![None; q.len()],
330    ];
331    let cand_work: Vec<(usize, usize)> = (0..2)
332        .flat_map(|m| (0..meshes[m].len()).map(move |ti| (m, ti)))
333        .filter(|&(m, ti)| {
334            let pr = &prims[m][ti];
335            !pr.points.is_empty() || !pr.segments.is_empty()
336        })
337        .collect();
338    begin_phase(progress, Phase::CandidatePoints, cand_work.len() as u64);
339    let cand_results = maybe_par_map_ct_progress(cand_work.len(), 16, token, progress, |i| {
340        let (m, ti) = cand_work[i];
341        let pr = &prims[m][ti];
342        let input = ArrangementInput {
343            points: pr.points.clone(),
344            segments: pr.segments.clone(),
345        };
346        arrangement::candidate_points(meshes[m][ti], &input, token)
347    })?;
348    for (&(m, ti), cands) in cand_work.iter().zip(cand_results) {
349        candidates[m][ti] = Some(cands?);
350    }
351
352    crate::timing::print("robust: candidate points", t_cand);
353    let t_reg = crate::timing::start();
354
355    // 4b. Original-edge registry: split points on each mesh edge (geometric
356    // identity — soups have no reliable connectivity). Bit-keyed: original
357    // edges join exact f64 vertices.
358    // Both registry sweeps are pure per triangle; workers collect local
359    // (key, point) hits and the single-threaded merge inserts them in
360    // worklist order. Registry values are sets, so content is
361    // order-independent and the merge order only preserves determinism of
362    // allocation, not meaning.
363    let reg_work: Vec<(usize, usize)> = (0..2)
364        .flat_map(|m| (0..meshes[m].len()).map(move |ti| (m, ti)))
365        .filter(|&(m, ti)| candidates[m][ti].is_some())
366        .collect();
367    // Two sweeps (original edges, then intersection segments) over the same
368    // worklist, so the phase total counts it twice.
369    begin_phase(progress, Phase::Registries, 2 * reg_work.len() as u64);
370
371    // Registry values are (points, seen) rather than BTreeSet: probes and
372    // dedup go through structural R3Key hashing, so the sequential merge
373    // never pays ordered rational comparisons, and the consuming `extra`
374    // sets see each point exactly once — the same content BTreeSet gave.
375    // Order invariance: the registry is only ever `get`/`entry`-probed, and
376    // its point lists land in a `BTreeSet<R3>` (`extra`) below, which sorts.
377    let mut edge_registry: [HashMap<BitEdgeKey, (Vec<R3>, HashSet<R3Key>)>; 2] =
378        [HashMap::default(), HashMap::default()];
379    let edge_hits = maybe_par_map_ct_progress(reg_work.len(), 16, token, progress, |i| {
380        let (m, ti) = reg_work[i];
381        let cands = candidates[m][ti].as_ref().expect("filtered to Some");
382        let t = meshes[m][ti];
383        let corners = [
384            R3::from_vec3(t[0]),
385            R3::from_vec3(t[1]),
386            R3::from_vec3(t[2]),
387        ];
388        let ca: [[f64; 3]; 3] = [
389            [t[0].x, t[0].y, t[0].z],
390            [t[1].x, t[1].y, t[1].z],
391            [t[2].x, t[2].y, t[2].z],
392        ];
393        let cands_a: Vec<[f64; 3]> = cands.iter().map(approx3).collect();
394        let mut hits: Vec<(BitEdgeKey, R3)> = Vec::new();
395        for e in 0..3 {
396            let a = &corners[e];
397            let b = &corners[(e + 1) % 3];
398            let key = bit_edge_key(t[e], t[(e + 1) % 3]);
399            let sbox = seg_box3(ca[e], ca[(e + 1) % 3]);
400            for (pt, pt_a) in cands.iter().zip(&cands_a) {
401                // A point on the edge lies inside its inflated box; the
402                // reject skips the exact comparisons for everything else.
403                if !box3_contains(&sbox, *pt_a) {
404                    continue;
405                }
406                if !r3_eq(pt, a)
407                    && !r3_eq(pt, b)
408                    && point_on_segment_f(*pt_a, pt, ca[e], a, ca[(e + 1) % 3], b)
409                {
410                    hits.push((key, pt.clone()));
411                }
412            }
413        }
414        hits
415    })?;
416    for (&(m, _), hits) in reg_work.iter().zip(&edge_hits) {
417        for (key, pt) in hits {
418            let e = edge_registry[m].entry(*key).or_default();
419            if e.1.insert(R3Key(pt.clone())) {
420                e.0.push(pt.clone());
421            }
422        }
423    }
424
425    // 4c. Intersection-segment registry: for every pair segment, gather the
426    // split points both sides know about.
427    // Hash-keyed with structural R3Key hashing: the map is only ever probed
428    // (entry/get), never iterated, and BTreeMap's exact rational comparisons
429    // per probe dominated this phase on segment-heavy meshes.
430    // Same order-invariance argument as `edge_registry`: probe-only, and the
431    // points it hands out are re-sorted through `extra: BTreeSet<R3>`.
432    let mut seg_splits: HashMap<(R3Key, R3Key), (Vec<R3>, HashSet<R3Key>)> = HashMap::default();
433    let split_hits = maybe_par_map_ct_progress(reg_work.len(), 16, token, progress, |i| {
434        let (m, ti) = reg_work[i];
435        let cands = candidates[m][ti].as_ref().expect("filtered to Some");
436        let cands_a: Vec<[f64; 3]> = cands.iter().map(approx3).collect();
437        let mut hits: Vec<(GeoEdgeKey, R3)> = Vec::new();
438        for (a, b, _prov) in &prims[m][ti].segments {
439            let key = geo_edge_key(a, b);
440            let (aa, ba) = (approx3(a), approx3(b));
441            let sbox = seg_box3(aa, ba);
442            for (pt, pt_a) in cands.iter().zip(&cands_a) {
443                if !box3_contains(&sbox, *pt_a) {
444                    continue;
445                }
446                if !r3_eq(pt, a) && !r3_eq(pt, b) && point_on_segment_f(*pt_a, pt, aa, a, ba, b) {
447                    hits.push((key.clone(), pt.clone()));
448                }
449            }
450        }
451        hits
452    })?;
453    for hits in &split_hits {
454        for (key, pt) in hits {
455            let e = seg_splits.entry(key.clone()).or_default();
456            if e.1.insert(R3Key(pt.clone())) {
457                e.0.push(pt.clone());
458            }
459        }
460    }
461
462    crate::timing::print("robust: split registries", t_reg);
463    let t_arr = crate::timing::start();
464
465    // 5. Build arrangements and emit pieces. The per-triangle arrangement
466    // (registry probes, CDT, crossings) is pure and runs in parallel; the
467    // interner is order-sensitive, so interning and piece emission replay
468    // the results strictly in worklist order — outputs are bit-identical to
469    // the sequential build.
470    enum TriResult {
471        /// Untouched triangle → whole piece, interned by f64 bits.
472        Untouched,
473        Arranged(arrangement::Arrangement),
474    }
475    let arr_work: Vec<(usize, usize)> = (0..2)
476        .flat_map(|m| (0..meshes[m].len()).map(move |ti| (m, ti)))
477        .filter(|&(m, ti)| live[m][ti])
478        .collect();
479    begin_phase(progress, Phase::Arrangements, arr_work.len() as u64);
480    let arr_results = maybe_par_map_ct_progress(arr_work.len(), 16, token, progress, |i| {
481        let (m, ti) = arr_work[i];
482        let t = meshes[m][ti];
483        let pr = &prims[m][ti];
484        // Boundary split points for this triangle (bit-keyed: uncut
485        // triangles probe with zero rational work).
486        let mut extra: BTreeSet<R3> = BTreeSet::new();
487        for e in 0..3 {
488            if let Some(set) = edge_registry[m].get(&bit_edge_key(t[e], t[(e + 1) % 3])) {
489                extra.extend(set.0.iter().cloned());
490            }
491        }
492        // Split points along this triangle's intersection segments
493        // discovered by the other side.
494        for (a, b, _) in &pr.segments {
495            if let Some(set) = seg_splits.get(&geo_edge_key(a, b)) {
496                extra.extend(set.0.iter().cloned());
497            }
498        }
499
500        if pr.points.is_empty() && pr.segments.is_empty() && extra.is_empty() {
501            return Some(TriResult::Untouched);
502        }
503        let mut input = ArrangementInput {
504            points: pr.points.clone(),
505            segments: pr.segments.clone(),
506        };
507        for pt in extra {
508            input.points.push((pt, usize::MAX));
509        }
510        arrangement::build(t, &input, token).map(TriResult::Arranged)
511    })?;
512
513    let mut pieces: Vec<Piece> = Vec::new();
514    // Membership-only set (never iterated by this crate); order-invariant.
515    let mut isect_edges: HashSet<EdgeKey> = HashSet::default();
516    let mut interner = VertInterner::default();
517    for (&(m, ti), result) in arr_work.iter().zip(arr_results) {
518        let t = meshes[m][ti];
519        match result? {
520            TriResult::Untouched => {
521                pieces.push(Piece {
522                    mesh: m as u8,
523                    tri: ti,
524                    vi: [
525                        interner.intern_f64(t[0]),
526                        interner.intern_f64(t[1]),
527                        interner.intern_f64(t[2]),
528                    ],
529                });
530            }
531            TriResult::Arranged(arr) => {
532                // Intern each arrangement point once; sub-triangles and
533                // constraint edges then only shuffle ids.
534                let ids: Vec<u32> = arr.points3.iter().map(|p| interner.intern(p)).collect();
535                for (u, w) in arr.constraints.keys() {
536                    isect_edges.insert(edge_key(ids[*u], ids[*w]));
537                }
538                for st in &arr.tris {
539                    let (a, b, c) = (st[0], st[1], st[2]);
540                    let vi = if arr.flipped {
541                        [ids[a], ids[c], ids[b]]
542                    } else {
543                        [ids[a], ids[b], ids[c]]
544                    };
545                    pieces.push(Piece {
546                        mesh: m as u8,
547                        tri: ti,
548                        vi,
549                    });
550                }
551            }
552        }
553    }
554
555    crate::timing::print("robust: arrangements", t_arr);
556    crate::timing::print_count(&format!(
557        "robust: arrangement phases: {}",
558        arrangement::stats::snapshot_and_reset()
559    ));
560
561    Some(IntersectionGraph {
562        pieces,
563        verts: interner.verts,
564        verts_f64: interner.verts_f64,
565        isect_edges,
566        any_intersections,
567    })
568}