Skip to main content

manifold_rust/robust/
cells.rs

1// robust/cells.rs — Arrangement cell complex and combinatorial winding
2// propagation (Zhou, Grinspun, Zorin, Jacobson 2016, "Mesh Arrangements for
3// Solid Geometry" — the formulation libigl's mesh_boolean uses).
4//
5// This replaces the per-component winding queries of robust/mod.rs with the
6// structure that makes local inconsistency unrepresentable. The pieces of
7// robust/intersection_graph.rs already form an intersection-free arrangement
8// of both operands; what this module adds is the *dual*:
9//
10//   1. Every piece has two sides (normal / anti). Around each arrangement
11//      edge the incident half-faces are radially sorted (the same exact
12//      basis + angle_cmp the ring regularization uses), and the wedge
13//      between consecutive radial positions unions the sides it touches.
14//      The resulting equivalence classes are the 3D cells of the
15//      arrangement.
16//   2. Winding numbers are then propagated cell-to-cell by breadth-first
17//      search: crossing a piece from its normal side to its anti side enters
18//      the solid its operand bounds, so w[mesh] increases by one. Because
19//      every cell's winding is *derived from one traversal*, adjacent
20//      regions cannot disagree — the failure mode of independent per-region
21//      queries (two pieces meeting along a segment both classified "outside",
22//      leaving a surface that cannot close) does not exist here.
23//
24// Coincident duplicate faces fall out correctly: they share a radial
25// position, so they form one "wall" whose winding step is the signed sum of
26// the stack. A doubled sheet steps the winding by two and a fold cancels to
27// zero, without any explicit regularization pass.
28
29use std::cmp::Ordering;
30// Fx hashing (unseeded) instead of SipHash: `classes` is probe-only, and
31// `by_tri` is iterated but its output is sorted by the unique representative
32// piece index right after, so hash order cannot reach the result.
33use rustc_hash::FxHashMap as HashMap;
34
35use crate::disjoint_sets::DisjointSets;
36use crate::linalg::Vec3;
37
38use super::exact::rational::R3;
39use super::exact::Sign;
40use super::intersection_graph::{edge_key, EdgeKey, IntersectionGraph};
41// The result-extraction half of this module (the containment predicate and
42// the boundary walk that turns cell labels into output pieces) lives in
43// `cells_extract.rs`; it is re-exported here so callers still say
44// `cells::extract` / `cells::in_result`.
45pub use super::cells_extract::{extract, in_result};
46
47/// Side of a piece: `NORMAL` is the half-space its outward normal points
48/// into, `ANTI` the one behind it (inside the solid its operand bounds).
49pub const NORMAL: usize = 0;
50pub const ANTI: usize = 1;
51
52/// Node id in the side union-find: two per piece.
53#[inline]
54fn node(piece: usize, side: usize) -> u32 {
55    (2 * piece + side) as u32
56}
57
58/// The arrangement's cell decomposition.
59pub struct CellComplex {
60    /// Compact cell id per (piece, side); index with [`node`].
61    pub cell_of: Vec<u32>,
62    pub num_cells: usize,
63    /// Distinct triangles of the arrangement, each with its coincident stack
64    /// collapsed into one winding step. Computed once here because both the
65    /// winding propagation and the extraction need it.
66    pub walls: Vec<Wall>,
67}
68
69impl CellComplex {
70    #[inline]
71    pub fn cell(&self, piece: usize, side: usize) -> usize {
72        self.cell_of[node(piece, side) as usize] as usize
73    }
74}
75
76/// The vertex tables the radial machinery reads: exact coordinates plus
77/// their cached correctly rounded f64 approximations. Passing them
78/// explicitly (rather than the whole `IntersectionGraph`) lets the output
79/// assembly reuse the fan sort on the extracted boundary, which has the same
80/// interned vertex ids but no graph of its own.
81#[derive(Clone, Copy)]
82pub struct VertTables<'a> {
83    pub verts: &'a [R3],
84    pub verts_f64: &'a [Vec3],
85}
86
87impl<'a> VertTables<'a> {
88    pub fn of(graph: &'a IntersectionGraph) -> Self {
89        VertTables {
90            verts: &graph.verts,
91            verts_f64: &graph.verts_f64,
92        }
93    }
94}
95
96/// One half-face incident to an arrangement edge.
97pub struct Inc {
98    /// Caller-defined id of the half-face. [`build_cells`] passes the
99    /// piece index; the output pairing (`robust::pairing`) passes the
100    /// half-edge index — the fan sort itself never interprets it.
101    pub id: usize,
102    /// Traversal runs key.0 → key.1 (the edge's canonical direction).
103    pub forward: bool,
104    /// Vertex id of the opposite (apex) vertex.
105    pub apex: u32,
106}
107
108/// The side of a half-face that faces counter-clockwise (increasing radial
109/// angle) around its edge.
110///
111/// A forward-traversing face has normal ∝ w × d, which sits 90° CCW of its
112/// apex direction, so its normal side is the CCW one; a backward face's
113/// normal is 90° CW, so the relationship inverts.
114#[inline]
115fn ccw_side(forward: bool) -> usize {
116    if forward {
117        NORMAL
118    } else {
119        ANTI
120    }
121}
122
123#[inline]
124fn cw_side(forward: bool) -> usize {
125    1 - ccw_side(forward)
126}
127
128impl Inc {
129    #[inline]
130    fn ccw_side(&self) -> usize {
131        ccw_side(self.forward)
132    }
133
134    #[inline]
135    fn cw_side(&self) -> usize {
136        cw_side(self.forward)
137    }
138}
139
140/// Build the cell complex over every piece of the graph.
141///
142/// Discarded/regularized pieces are deliberately *not* excluded: thin
143/// material cancels arithmetically in the winding sum, which is both simpler
144/// and more robust than deciding up front which sheets are real.
145pub fn build_cells(graph: &IntersectionGraph) -> CellComplex {
146    build_cells_with_token(graph, None).expect("uncancellable build_cells cannot cancel")
147}
148
149/// [`build_cells`] with cooperative cancellation, checked once per
150/// arrangement edge. Returns `None` when the token fires.
151pub fn build_cells_with_token(
152    graph: &IntersectionGraph,
153    token: Option<&crate::cancel::CancelToken>,
154) -> Option<CellComplex> {
155    build_cells_with_progress(graph, token, None)
156}
157
158/// [`build_cells_with_token`] that also reports the arrangement-edge sweep's
159/// fraction to `progress`. `None` costs nothing (see [`crate::progress`]).
160pub fn build_cells_with_progress(
161    graph: &IntersectionGraph,
162    token: Option<&crate::cancel::CancelToken>,
163    progress: Option<&crate::progress::ProgressReporter>,
164) -> Option<CellComplex> {
165    let n = graph.pieces.len();
166    let vt = VertTables::of(graph);
167    let ds = DisjointSets::new((2 * n).max(1) as u32);
168
169    // Incident half-faces per edge, as one flat array sorted by edge rather
170    // than a hash entry owning its own Vec: the allocation churn of ~3n tiny
171    // Vecs dominated cell construction on large arrangements.
172    let mut incident: Vec<(EdgeKey, usize, bool, u32)> = Vec::with_capacity(3 * n);
173    for (pi, piece) in graph.pieces.iter().enumerate() {
174        let vi = piece.vi;
175        for e in 0..3 {
176            let (a, b) = (vi[e], vi[(e + 1) % 3]);
177            incident.push((edge_key(a, b), pi, a < b, vi[(e + 2) % 3]));
178        }
179    }
180    incident.sort_unstable();
181
182    crate::progress::begin_phase(progress, crate::progress::Phase::Cells, incident.len() as u64);
183    let mut at = 0;
184    while at < incident.len() {
185        if crate::cancel::is_cancelled(token) {
186            return None;
187        }
188        let key = &incident[at].0;
189        let mut end = at + 1;
190        while end < incident.len() && incident[end].0 == *key {
191            end += 1;
192        }
193        let raw = &incident[at..end];
194        if let Some(pr) = progress {
195            pr.advance((end - at) as u64);
196        }
197        at = end;
198        if raw.len() < 2 {
199            continue; // a boundary edge bounds no wedge
200        }
201        // Ordinary manifold edge whose two faces are provably in different
202        // radial directions: the cyclic order is trivial, so both wedges
203        // link with no exact angle work at all. These edges outnumber the
204        // rest by a wide margin, and computing rational radial directions
205        // for them dominated cell construction. The filter must certify
206        // non-coplanarity — a coincident pair has one radial position, not
207        // two, and linking it as two would fuse the sheet's own sides.
208        if raw.len() == 2 {
209            let pt = |v: u32| {
210                let p = graph.verts_f64[v as usize];
211                [p.x, p.y, p.z]
212            };
213            let sign =
214                super::exact::approx::orient3d_a(pt(key.0), pt(key.1), pt(raw[0].3), pt(raw[1].3));
215            if matches!(sign, Some(Sign::Neg) | Some(Sign::Pos)) {
216                let (p0, fw0) = (raw[0].1, raw[0].2);
217                let (p1, fw1) = (raw[1].1, raw[1].2);
218                ds.unite(node(p0, ccw_side(fw0)), node(p1, cw_side(fw1)));
219                ds.unite(node(p1, ccw_side(fw1)), node(p0, cw_side(fw0)));
220                continue;
221            }
222        }
223        let Some((incs, groups)) = radial_fan(key.0, key.1, raw, vt) else {
224            continue;
225        };
226        for gi in 0..groups.len() {
227            let (s, e) = groups[gi];
228            // All faces of a wall share the cell on each of its two sides.
229            for k in s..e {
230                ds.unite(
231                    node(incs[s].id, incs[s].ccw_side()),
232                    node(incs[k].id, incs[k].ccw_side()),
233                );
234                ds.unite(
235                    node(incs[s].id, incs[s].cw_side()),
236                    node(incs[k].id, incs[k].cw_side()),
237                );
238            }
239            // The wedge between this wall and the next: CCW side of this
240            // wall meets the CW side of the next.
241            let (ns, _) = groups[(gi + 1) % groups.len()];
242            if groups.len() > 1 {
243                ds.unite(
244                    node(incs[s].id, incs[s].ccw_side()),
245                    node(incs[ns].id, incs[ns].cw_side()),
246                );
247            }
248        }
249    }
250
251    // Compact the union-find roots into dense cell ids. Roots are already
252    // node ids, so a flat table beats hashing here.
253    let mut cell_of = vec![0u32; 2 * n];
254    let mut remap = vec![u32::MAX; 2 * n];
255    let mut num_cells = 0u32;
256    for i in 0..(2 * n) {
257        let root = ds.find(i as u32) as usize;
258        if remap[root] == u32::MAX {
259            remap[root] = num_cells;
260            num_cells += 1;
261        }
262        cell_of[i] = remap[root];
263    }
264    Some(CellComplex {
265        num_cells: num_cells as usize,
266        cell_of,
267        walls: walls(graph),
268    })
269}
270
271/// Filtered orientation of apex `b` against apex `a` around the directed
272/// edge `k0 → k1`: `Pos` means `b` sits CCW of `a` by less than a half turn.
273/// The f64 filter (on the cached correctly rounded approximations) certifies
274/// almost every query; only near-coplanar apex pairs escalate to the exact
275/// rational determinant.
276fn orient_edge(vt: VertTables, k0: u32, k1: u32, a: u32, b: u32) -> Sign {
277    let pt = |v: u32| {
278        let p = vt.verts_f64[v as usize];
279        [p.x, p.y, p.z]
280    };
281    match super::exact::approx::orient3d_a(pt(k0), pt(k1), pt(a), pt(b)) {
282        Some(s @ (Sign::Pos | Sign::Neg)) => s,
283        _ => super::exact::predicates::orient3d_r(
284            &vt.verts[k0 as usize],
285            &vt.verts[k1 as usize],
286            &vt.verts[a as usize],
287            &vt.verts[b as usize],
288        ),
289    }
290}
291
292/// Exact radial direction of `a`'s apex about edge `k0 → k1`: the cross
293/// product `(k1−k0) × (a−k0)`. Zero iff the apex lies on the edge's axis.
294fn radial_cross(vt: VertTables, k0: u32, k1: u32, a: u32) -> R3 {
295    let w = vt.verts[k1 as usize].sub(&vt.verts[k0 as usize]);
296    let d = vt.verts[a as usize].sub(&vt.verts[k0 as usize]);
297    w.cross(&d)
298}
299
300/// A value with a rigorous absolute error bound, tracked through the few
301/// operations the fan filters need. Inputs are correctly rounded f64
302/// approximations of exact rationals (error ≤ 0.5 ulp ≤ u·|x|), and every
303/// operation adds its own rounding term — the bound is conservative by
304/// construction, so a certified sign is exact. This matters because the fan
305/// geometry routinely subtracts nearly equal coordinates, where the input
306/// rounding error dwarfs a magnitude bound taken on the *differences*.
307#[derive(Clone, Copy)]
308struct Approx {
309    v: f64,
310    err: f64,
311}
312
313const U: f64 = f64::EPSILON;
314
315impl Approx {
316    /// A correctly rounded approximation of an exact value.
317    fn input(v: f64) -> Self {
318        Approx { v, err: U * v.abs() }
319    }
320    fn sub(self, o: Approx) -> Self {
321        let v = self.v - o.v;
322        Approx { v, err: self.err + o.err + U * v.abs() }
323    }
324    fn mul(self, o: Approx) -> Self {
325        let v = self.v * o.v;
326        Approx {
327            v,
328            err: self.v.abs() * o.err + self.err * o.v.abs() + self.err * o.err + U * v.abs(),
329        }
330    }
331    fn add(self, o: Approx) -> Self {
332        let v = self.v + o.v;
333        Approx { v, err: self.err + o.err + U * v.abs() }
334    }
335    fn sign(self) -> Option<Sign> {
336        if self.v.abs() > self.err {
337            Some(if self.v > 0.0 { Sign::Pos } else { Sign::Neg })
338        } else {
339            None
340        }
341    }
342}
343
344/// The three components of `(k1−k0) × (a−k0)` with error bounds.
345fn radial_cross_a(vt: VertTables, k0: u32, k1: u32, a: u32) -> [Approx; 3] {
346    let p = |v: u32| {
347        let p = vt.verts_f64[v as usize];
348        [Approx::input(p.x), Approx::input(p.y), Approx::input(p.z)]
349    };
350    let (p0, p1, pa) = (p(k0), p(k1), p(a));
351    let w = [p1[0].sub(p0[0]), p1[1].sub(p0[1]), p1[2].sub(p0[2])];
352    let d = [pa[0].sub(p0[0]), pa[1].sub(p0[1]), pa[2].sub(p0[2])];
353    [
354        w[1].mul(d[2]).sub(w[2].mul(d[1])),
355        w[2].mul(d[0]).sub(w[0].mul(d[2])),
356        w[0].mul(d[1]).sub(w[1].mul(d[0])),
357    ]
358}
359
360/// Is the apex on the edge's axis (a degenerate sliver with no wedge)?
361/// A certifiably nonzero cross component proves off-axis; only near-axis
362/// apexes pay for the exact cross.
363fn on_axis(vt: VertTables, k0: u32, k1: u32, a: u32) -> bool {
364    if radial_cross_a(vt, k0, k1, a)
365        .iter()
366        .any(|c| c.sign().is_some())
367    {
368        return false;
369    }
370    radial_cross(vt, k0, k1, a).is_zero()
371}
372
373/// For two apexes whose radial directions are exactly parallel (orient_edge
374/// returned Zero), do they point the same way (`Pos`, a coincident stack) or
375/// opposite ways (`Neg`, a fold)? Sign of the dot of the two radial crosses;
376/// exact fallback only when the error-tracked f64 dot cannot certify.
377fn same_ray_sign(vt: VertTables, k0: u32, k1: u32, a: u32, b: u32) -> Sign {
378    let ca = radial_cross_a(vt, k0, k1, a);
379    let cb = radial_cross_a(vt, k0, k1, b);
380    let dot = ca[0]
381        .mul(cb[0])
382        .add(ca[1].mul(cb[1]))
383        .add(ca[2].mul(cb[2]));
384    if let Some(s) = dot.sign() {
385        return s;
386    }
387    let ea = radial_cross(vt, k0, k1, a);
388    let eb = radial_cross(vt, k0, k1, b);
389    let exact = &ea.x * &eb.x + &ea.y * &eb.y + &ea.z * &eb.z;
390    Sign::of_rat(&exact)
391}
392
393/// Radially sort the incident half-faces of one arrangement edge and group
394/// coincident directions, returning `None` when fewer than two off-axis
395/// faces remain.
396///
397/// The cyclic CCW order is derived from filtered orient3d queries against a
398/// reference apex (the first off-axis face) instead of exact coordinates in
399/// a rational basis: classify every face into {reference ray, CCW half,
400/// opposite ray, CW half}, then sort each open half by pairwise orientation.
401/// Two faces compare Equal exactly when their radial directions coincide, so
402/// the Equal-runs are the coincident walls. The starting point of a cyclic
403/// order is immaterial to the wedge links, which lets the whole fan run on
404/// the f64 filter in the common case — the rational-basis construction this
405/// replaces dominated cell construction on self-intersecting scans.
406pub fn radial_fan(
407    k0: u32,
408    k1: u32,
409    raw: &[(EdgeKey, usize, bool, u32)],
410    vt: VertTables,
411) -> Option<(Vec<Inc>, Vec<(usize, usize)>)> {
412    let mut incs: Vec<Inc> = raw
413        .iter()
414        .filter(|&&(_, _, _, apex)| !on_axis(vt, k0, k1, apex))
415        .map(|&(_, id, forward, apex)| Inc { id, forward, apex })
416        .collect();
417    if incs.len() < 2 {
418        return None;
419    }
420    // Class of each face relative to the reference apex: 0 = on the
421    // reference ray, 1 = strictly CCW of it (first half turn), 2 = on the
422    // opposite ray, 3 = strictly CW (second half turn).
423    let r = incs[0].apex;
424    let class = |apex: u32| -> u8 {
425        if apex == r {
426            return 0;
427        }
428        match orient_edge(vt, k0, k1, r, apex) {
429            Sign::Pos => 1,
430            Sign::Neg => 3,
431            Sign::Zero => match same_ray_sign(vt, k0, k1, r, apex) {
432                Sign::Pos => 0,
433                Sign::Neg => 2,
434                Sign::Zero => unreachable!("parallel nonzero radial rays have nonzero dot"),
435            },
436        }
437    };
438    let classes: HashMap<u32, u8> = incs.iter().map(|i| (i.apex, class(i.apex))).collect();
439    incs.sort_by(|a, b| {
440        let (ca, cb) = (classes[&a.apex], classes[&b.apex]);
441        ca.cmp(&cb)
442            .then_with(|| {
443                if ca != 1 && ca != 3 || a.apex == b.apex {
444                    Ordering::Equal // same ray by class
445                } else {
446                    // Within an open half turn, Zero means the same ray (the
447                    // opposite ray would land in the other class).
448                    match orient_edge(vt, k0, k1, a.apex, b.apex) {
449                        Sign::Pos => Ordering::Less,
450                        Sign::Neg => Ordering::Greater,
451                        Sign::Zero => Ordering::Equal,
452                    }
453                }
454            })
455            .then_with(|| a.id.cmp(&b.id))
456    });
457
458    // Equal-direction runs become the walls.
459    let mut groups = Vec::new();
460    let mut i = 0;
461    while i < incs.len() {
462        let mut j = i + 1;
463        while j < incs.len() && {
464            let (ci, cj) = (classes[&incs[i].apex], classes[&incs[j].apex]);
465            ci == cj
466                && (ci == 0
467                    || ci == 2
468                    || incs[i].apex == incs[j].apex
469                    || orient_edge(vt, k0, k1, incs[i].apex, incs[j].apex) == Sign::Zero)
470        } {
471            j += 1;
472        }
473        groups.push((i, j));
474        i = j;
475    }
476    Some((incs, groups))
477}
478
479/// Per-cell winding numbers, one entry per operand.
480pub struct Windings {
481    /// `w[cell] = [w_P, w_Q]`, valid only where `known[cell]`.
482    pub w: Vec<[i32; 2]>,
483    pub known: Vec<bool>,
484}
485
486impl Windings {
487    /// Every cell resolved — no residual point queries needed.
488    pub fn complete(&self) -> bool {
489        self.known.iter().all(|&k| k)
490    }
491}
492
493/// Winding numbers for *every* cell.
494///
495/// The combinatorial BFS only reaches cells connected through shared
496/// arrangement edges, so disjoint or nested components need a seed each:
497/// exactly the residual ray-shooting libigl's `propagate_winding_numbers`
498/// performs. One exact query pair per component, not per surface region.
499/// Winding numbers for every cell of the arrangement.
500///
501/// Each connected component is anchored by one exact point query and the
502/// rest of its cells follow combinatorially, so the expensive part scales
503/// with the number of components rather than the number of regions.
504///
505/// The anchor is deliberately measured rather than deduced. Identifying the
506/// unbounded cell combinatorially — take the lexicographically extreme
507/// vertex, pick the incident face most nearly perpendicular to x, call its
508/// outward side unbounded — is wrong whenever that face's outward side holds
509/// material, which happens on real scans (a thin shell whose rim reaches the
510/// extreme vertex). The failure is silent and total: anchoring an interior
511/// cell at zero shifts every winding by a constant, and `w ≥ 1` then
512/// excludes almost the whole model. Two Thingi10K unions collapsed from
513/// 51372 and 5856 triangles to 4 and 40 that way.
514pub fn windings(
515    graph: &IntersectionGraph,
516    complex: &CellComplex,
517    tris: [&[[Vec3; 3]]; 2],
518) -> Windings {
519    let rat = [to_rational(tris[0]), to_rational(tris[1])];
520    let bx = [tri_boxes(tris[0]), tri_boxes(tris[1])];
521    let mut out = Windings {
522        w: vec![[0i32; 2]; complex.num_cells],
523        known: vec![false; complex.num_cells],
524    };
525    seed_unreached(
526        graph,
527        complex,
528        &mut out,
529        tris,
530        [&rat[0], &rat[1]],
531        [&bx[0], &bx[1]],
532    );
533    out
534}
535
536fn to_rational(tris: &[[Vec3; 3]]) -> Vec<[R3; 3]> {
537    tris.iter()
538        .map(|t| [R3::from_vec3(t[0]), R3::from_vec3(t[1]), R3::from_vec3(t[2])])
539        .collect()
540}
541
542fn tri_boxes(tris: &[[Vec3; 3]]) -> Vec<crate::types::Box> {
543    tris.iter()
544        .map(|t| {
545            let mut b = crate::types::Box::from_points(t[0], t[1]);
546            b.union_point(t[2]);
547            b
548        })
549        .collect()
550}
551
552/// Resolve whatever the outer traversal could not reach — disjoint or nested
553/// components — with one exact query pair each.
554///
555/// Returns immediately when everything is already known, so callers can hold
556/// off building the rational and bounding-box tables until this says it
557/// needs them.
558pub fn seed_unreached(
559    graph: &IntersectionGraph,
560    complex: &CellComplex,
561    out: &mut Windings,
562    tris_f64: [&[[Vec3; 3]]; 2],
563    tris_r: [&[[R3; 3]]; 2],
564    boxes: [&[crate::types::Box]; 2],
565) {
566    if out.complete() {
567        return;
568    }
569    let adj = cell_adjacency(complex);
570
571    // A representative (piece, side) per cell, for seeding by point query.
572    let mut rep: Vec<Option<(usize, usize)>> = vec![None; complex.num_cells];
573    for pi in 0..graph.pieces.len() {
574        for side in [NORMAL, ANTI] {
575            let c = complex.cell(pi, side);
576            if rep[c].is_none() {
577                rep[c] = Some((pi, side));
578            }
579        }
580    }
581
582    for c in 0..complex.num_cells {
583        if out.known[c] {
584            continue;
585        }
586        let Some((pi, side)) = rep[c] else { continue };
587        let pv = graph.piece_verts(pi);
588        let point = super::ray_shoot::piece_centroid(pv);
589        let n = pv[1].sub(pv[0]).cross(&pv[2].sub(pv[0]));
590        let outward = if side == NORMAL {
591            n
592        } else {
593            R3::new(-&n.x, -&n.y, -&n.z)
594        };
595        let mut w = [0i32; 2];
596        for m in 0..2 {
597            w[m] = super::ray_shoot::winding_off_surface(
598                &point,
599                &outward,
600                tris_r[m],
601                tris_f64[m],
602                boxes[m],
603            );
604        }
605        seed(out, c, w);
606        bfs(&adj, out, c);
607    }
608}
609
610fn seed(out: &mut Windings, cell: usize, w: [i32; 2]) {
611    out.w[cell] = w;
612    out.known[cell] = true;
613}
614
615/// Winding step between adjacent cells, **summed** over every piece that
616/// separates them.
617///
618/// Aggregation is what gives coincident stacks their multiplicity: a doubled
619/// sheet contributes +2 and a fold cancels to 0, so self-overlapping input
620/// classifies correctly with no separate regularization pass. Treating the
621/// coincident pieces as independent adjacencies would apply only the first
622/// and silently lose the rest.
623fn cell_adjacency(complex: &CellComplex) -> Vec<Vec<(usize, [i32; 2])>> {
624    let mut adj: Vec<Vec<(usize, [i32; 2])>> = vec![Vec::new(); complex.num_cells];
625    for &Wall { rep, delta } in &complex.walls {
626        let (cn, ca) = (complex.cell(rep, NORMAL), complex.cell(rep, ANTI));
627        if cn == ca {
628            continue; // both sides in one cell: the sheet bounds nothing
629        }
630        adj[cn].push((ca, delta));
631        adj[ca].push((cn, [-delta[0], -delta[1]]));
632    }
633    // Every wall stays its own edge — collapsing them by cell pair would let
634    // an arbitrary one win, which is both order-dependent and lossy. Sorting
635    // makes the traversal deterministic regardless of hash iteration order.
636    for a in adj.iter_mut() {
637        a.sort_unstable();
638        a.dedup();
639    }
640    adj
641}
642
643/// Walls whose winding step disagrees with the cells' resolved windings.
644///
645/// The difference between two cells is well defined, so a disagreement means
646/// the complex merged cells that the geometry keeps apart. Used by tests and
647/// diagnostics; a clean arrangement returns an empty list.
648pub fn inconsistent_walls(
649    complex: &CellComplex,
650    wind: &Windings,
651) -> Vec<(usize, [i32; 2], [i32; 2])> {
652    let mut bad = Vec::new();
653    for &Wall { rep, delta } in &complex.walls {
654        let (cn, ca) = (complex.cell(rep, NORMAL), complex.cell(rep, ANTI));
655        if cn == ca || !wind.known[cn] || !wind.known[ca] {
656            continue;
657        }
658        let actual = [
659            wind.w[ca][0] - wind.w[cn][0],
660            wind.w[ca][1] - wind.w[cn][1],
661        ];
662        if actual != delta {
663            bad.push((rep, delta, actual));
664        }
665    }
666    bad
667}
668
669/// One distinct triangle of the arrangement, with the coincident stack that
670/// occupies it collapsed into a single winding step.
671#[derive(Clone, Copy)]
672pub struct Wall {
673    /// Representative piece; its winding fixes the wall's normal side.
674    pub rep: usize,
675    /// Winding change per operand, crossing the representative's normal side
676    /// to its anti side.
677    pub delta: [i32; 2],
678}
679
680/// Group pieces into walls by exact triangle identity.
681///
682/// Pieces occupying the same triangle are a coincident stack whose
683/// contributions add — a doubled sheet steps by two, a fold cancels to zero.
684/// Two *different* triangles between the same pair of cells are alternative
685/// crossings of one boundary and are never summed; that distinction is why
686/// aggregation keys on the triangle rather than the cell pair.
687fn walls(graph: &IntersectionGraph) -> Vec<Wall> {
688    let mut by_tri: HashMap<[u32; 3], (usize, bool, [i32; 2])> = HashMap::default();
689    for (pi, piece) in graph.pieces.iter().enumerate() {
690        let (key, parity) = canonical(piece.vi);
691        let m = piece.mesh as usize;
692        let entry = by_tri.entry(key).or_insert((pi, parity, [0; 2]));
693        // Opposite winding means this piece's normal side is the
694        // representative's anti side, so it steps the other way.
695        entry.2[m] += if parity == entry.1 { 1 } else { -1 };
696    }
697    let mut out: Vec<Wall> = by_tri
698        .into_values()
699        .map(|(rep, _, delta)| Wall { rep, delta })
700        .collect();
701    // Hash iteration order must not reach the output: sort so extraction
702    // emits triangles in a stable order across runs.
703    out.sort_unstable_by_key(|w| w.rep);
704    out
705}
706
707/// Canonical key for a triangle (sorted vertex ids) plus the parity of the
708/// piece's winding against that order — the same identity the coincident
709/// binding uses, so both agree on what "the same triangle" means.
710fn canonical(vi: [u32; 3]) -> ([u32; 3], bool) {
711    let mut sorted = vi;
712    sorted.sort_unstable();
713    let i = (0..3).min_by_key(|&i| vi[i]).unwrap_or(0);
714    let rotated = [vi[i], vi[(i + 1) % 3], vi[(i + 2) % 3]];
715    (sorted, rotated == sorted)
716}
717
718fn bfs(adj: &[Vec<(usize, [i32; 2])>], out: &mut Windings, start: usize) {
719    let mut queue = std::collections::VecDeque::from([start]);
720    while let Some(c) = queue.pop_front() {
721        let base = out.w[c];
722        for &(next, d) in &adj[c] {
723            if out.known[next] {
724                continue;
725            }
726            seed(out, next, [base[0] + d[0], base[1] + d[1]]);
727            queue.push_back(next);
728        }
729    }
730}
731
732
733#[cfg(test)]
734#[path = "cells_tests.rs"]
735mod tests;