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;