manifold_rust/robust/classify.rs
1// robust/classify.rs — Radial regularization around intersection segments
2// and coincident-piece binding (paper §7.1).
3//
4// For every exact intersection sub-edge (robust/intersection_graph.rs), the
5// incident pieces from both meshes are sorted radially around the edge with
6// pure sign arithmetic (quadrant + 2D cross, no trigonometry). Within each
7// coincident-direction group, opposite-traversal pairs are infinitely thin
8// material and are discarded (regularization, §7.1). Exactly coincident
9// whole pieces (coplanar overlaps): opposite-orientation pairs are likewise
10// discarded; same-orientation pairs get one ∪ and one ∩ binding so each
11// shared region survives exactly once per output.
12//
13// The absolute ∪/∩ tags for everything else come from exact winding-number
14// queries in robust/mod.rs (one per surface component, per piece for
15// self-intersecting operands). The paper's local Prop 2/3 ring walk was
16// replaced by those queries: it silently misclassifies pieces where an
17// operand's winding exceeds 1 (self-overlapping sheets make a 1↔2 crossing
18// locally indistinguishable from the 0↔1 crossing the walk assumes).
19
20use std::cmp::Ordering;
21use num_rational::BigRational;
22use num_traits::{Signed, Zero};
23
24use super::exact::rational::R3;
25use super::exact::Sign;
26use super::intersection_graph::{edge_key, EdgeKey, IntersectionGraph};
27
28/// Which boolean output a piece belongs to.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum Tag {
31 Union,
32 Inter,
33}
34
35/// Per-piece classification result. Tags are set only for coincident-bound
36/// pieces; everything else is `None`, resolved by flood fill + winding
37/// queries in robust/mod.rs.
38pub struct Classification {
39 pub tags: Vec<Option<Tag>>,
40 pub discarded: Vec<bool>,
41}
42
43struct Incident {
44 piece: usize,
45 /// Piece winding traverses key.0 → key.1.
46 forward: bool,
47 /// The vertex opposite the ring edge; source of the radial direction.
48 apex: R3,
49 /// Radial direction of the apex: exact (d·u, d·v) coordinates, filled in
50 /// once the ring's basis exists.
51 du: BigRational,
52 dv: BigRational,
53}
54
55/// CCW angular comparison of two nonzero direction vectors.
56fn angle_cmp(a: (&BigRational, &BigRational), b: (&BigRational, &BigRational)) -> Ordering {
57 fn quadrant(du: &BigRational, dv: &BigRational) -> u8 {
58 let (su, sv) = (Sign::of_rat(du), Sign::of_rat(dv));
59 debug_assert!(!(su == Sign::Zero && sv == Sign::Zero), "zero direction");
60 match (su, sv) {
61 (Sign::Pos, Sign::Pos) | (Sign::Pos, Sign::Zero) => 0,
62 (Sign::Zero, Sign::Pos) | (Sign::Neg, Sign::Pos) => 1,
63 (Sign::Neg, Sign::Zero) | (Sign::Neg, Sign::Neg) => 2,
64 _ => 3,
65 }
66 }
67 let (qa, qb) = (quadrant(a.0, a.1), quadrant(b.0, b.1));
68 if qa != qb {
69 return qa.cmp(&qb);
70 }
71 // Same quadrant: CCW order by cross-product sign. Cleared of the four
72 // (positive) denominators so unreduced fractions compare without gcds:
73 // sign(a0·b1 − a1·b0) = sign(n_a0·n_b1·d_a1·d_b0 − n_a1·n_b0·d_a0·d_b1)
74 let lhs = a.0.numer() * b.1.numer() * a.1.denom() * b.0.denom();
75 let rhs = a.1.numer() * b.0.numer() * a.0.denom() * b.1.denom();
76 // Descending cross sign = CCW order: Pos → Less, Neg → Greater.
77 rhs.cmp(&lhs)
78}
79
80/// Coincident-piece regularization and binding (paper §7.1 done globally
81/// rather than per ring, so every ring sees one consistent decision):
82/// exactly-equal pieces are grouped by their sorted vertex triple and
83/// reduced in two stages.
84///
85/// Within one mesh first: a mesh's own exactly-coincident pieces are
86/// degenerate sheets of that operand. Opposite-winding pairs (a fold touching
87/// itself, or a zero-thickness flap) are infinitely thin material and cancel;
88/// same-winding stacks (a doubled/tripled surface — e.g. a Thingi10K scan
89/// whose STL repeats every facet, making the operand a multiple cover) are
90/// one surface element that any regularized boundary may contain at most
91/// once, so a single representative survives. The winding-based component
92/// classification in robust/mod.rs then decides that representative exactly
93/// as it would the single-cover surface — the winding queries run against the
94/// full original soup, where the multiplicity is still visible.
95///
96/// Then across meshes, on the reduced (≤1 piece per mesh) groups:
97/// opposite-winding pairs are again thin material and get discarded; a
98/// same-winding pair is a region shared by both surfaces that each output
99/// keeps exactly once, so the P copy is bound to Union and the Q copy to
100/// Inter. (The paper notes the choice is arbitrary; making it globally per
101/// piece, not per ring, is what keeps it conflict-free.)
102fn bind_coincident(
103 graph: &IntersectionGraph,
104 tags: &mut [Option<Tag>],
105 discarded: &mut [bool],
106) {
107 // Canonical key: sorted interned-id triple; parity bit = winding
108 // orientation relative to the sorted order. (Any consistent canonical
109 // order works — parity is only compared between pieces with the same
110 // key, and a different canonical order flips both parities together.)
111 let mut by_key: std::collections::HashMap<[u32; 3], Vec<(usize, bool)>> =
112 std::collections::HashMap::new();
113 for (pi, piece) in graph.pieces.iter().enumerate() {
114 let mut sorted = piece.vi;
115 sorted.sort_unstable();
116 // parity: does the winding cycle (v0,v1,v2), rotated to start at the
117 // smallest vertex, match (sorted0, sorted1, sorted2)?
118 let start = piece.vi.iter().position(|v| *v == sorted[0]).unwrap();
119 let same = piece.vi[(start + 1) % 3] == sorted[1];
120 by_key.entry(sorted).or_default().push((pi, same));
121 }
122 // Same-mesh reduction: cancel opposite-parity pairs, then keep only the
123 // lowest-indexed survivor of the remaining same-parity stack.
124 let reduce = |side: &mut Vec<(usize, bool)>, discarded: &mut [bool]| {
125 let mut fwd: Vec<usize> = side.iter().filter(|e| e.1).map(|e| e.0).collect();
126 let mut bwd: Vec<usize> = side.iter().filter(|e| !e.1).map(|e| e.0).collect();
127 // NB: popping both inside one `while let` tuple pattern would consume
128 // (and silently un-discard) a piece when only one list has any left.
129 while !fwd.is_empty() && !bwd.is_empty() {
130 discarded[fwd.pop().unwrap()] = true;
131 discarded[bwd.pop().unwrap()] = true;
132 }
133 for &pi in fwd.iter().chain(&bwd).skip(1) {
134 discarded[pi] = true;
135 }
136 side.retain(|&(pi, _)| !discarded[pi]);
137 debug_assert!(side.len() <= 1);
138 };
139 for owners in by_key.values() {
140 if owners.len() < 2 {
141 continue;
142 }
143 let mut p_side: Vec<(usize, bool)> = Vec::new();
144 let mut q_side: Vec<(usize, bool)> = Vec::new();
145 for &(pi, parity) in owners {
146 if graph.pieces[pi].mesh == 0 {
147 p_side.push((pi, parity));
148 } else {
149 q_side.push((pi, parity));
150 }
151 }
152 reduce(&mut p_side, discarded);
153 reduce(&mut q_side, discarded);
154 if let (Some(&(pp, p_parity)), Some(&(qp, q_parity))) =
155 (p_side.first(), q_side.first())
156 {
157 if p_parity != q_parity {
158 // Coincident with opposite winding: thin material, cancel.
159 discarded[pp] = true;
160 discarded[qp] = true;
161 } else {
162 tags[pp] = Some(Tag::Union);
163 tags[qp] = Some(Tag::Inter);
164 }
165 }
166 }
167}
168
169/// Regularize every intersection ring (cancel coincident opposite-traversal
170/// pieces) and bind exactly-coincident cross-mesh piece pairs.
171pub fn classify_rings(graph: &IntersectionGraph) -> Classification {
172 let n = graph.pieces.len();
173 let mut tags: Vec<Option<Tag>> = vec![None; n];
174 let mut discarded = vec![false; n];
175
176 bind_coincident(graph, &mut tags, &mut discarded);
177
178 // Ring construction: intersection edge key → incident pieces (globally
179 // discarded pieces excluded up front).
180 let mut rings: std::collections::HashMap<EdgeKey, Vec<Incident>> =
181 std::collections::HashMap::new();
182 for (pi, piece) in graph.pieces.iter().enumerate() {
183 if discarded[pi] {
184 continue;
185 }
186 for e in 0..3 {
187 let a = piece.vi[e];
188 let b = piece.vi[(e + 1) % 3];
189 let key = edge_key(a, b);
190 if !graph.isect_edges.contains(&key) {
191 continue;
192 }
193 let forward = a == key.0; // winding visits key.0 → key.1
194 let apex = &graph.verts[piece.vi[(e + 2) % 3] as usize];
195 rings.entry(key).or_default().push(Incident {
196 piece: pi,
197 forward,
198 apex: apex.clone(),
199 du: BigRational::zero(),
200 dv: BigRational::zero(),
201 });
202 }
203 }
204
205 for (key, incidents) in rings.iter_mut() {
206 regularize_one_ring(
207 &graph.verts[key.0 as usize],
208 &graph.verts[key.1 as usize],
209 incidents,
210 &mut discarded,
211 );
212 }
213
214 Classification { tags, discarded }
215}
216
217fn regularize_one_ring(k0: &R3, k1: &R3, incidents: &mut [Incident], discarded: &mut [bool]) {
218 // Radial basis: w along the edge, u ⊥ w via the axis of w's smallest
219 // |component| (never parallel), v = w × u; (u, v, w) is right-handed.
220 let w = k1.sub(k0);
221 let ax = w.x.abs();
222 let ay = w.y.abs();
223 let az = w.z.abs();
224 let unit = |i: usize| {
225 let z = BigRational::zero;
226 let o = || BigRational::from_integer(1.into());
227 match i {
228 0 => R3::new(o(), z(), z()),
229 1 => R3::new(z(), o(), z()),
230 _ => R3::new(z(), z(), o()),
231 }
232 };
233 let k = if ax <= ay && ax <= az {
234 0
235 } else if ay <= az {
236 1
237 } else {
238 2
239 };
240 let u = w.cross(&unit(k));
241 debug_assert!(!u.is_zero());
242 let v = w.cross(&u);
243
244 for inc in incidents.iter_mut() {
245 // Unreduced (a−o)·basis fractions — sign/compare-only consumers, so
246 // skipping gcd normalization is free speed (see dot_diff_raw).
247 let (du_n, du_d) = super::exact::predicates::dot_diff_raw(&inc.apex, k0, &u);
248 let (dv_n, dv_d) = super::exact::predicates::dot_diff_raw(&inc.apex, k0, &v);
249 inc.du = num_rational::BigRational::new_raw(du_n, du_d);
250 inc.dv = num_rational::BigRational::new_raw(dv_n, dv_d);
251 debug_assert!(
252 !(inc.du.is_zero() && inc.dv.is_zero()),
253 "apex on the ring axis"
254 );
255 }
256
257 // CCW radial sort; coincident directions tie-break by piece id for
258 // determinism.
259 incidents.sort_by(|a, b| {
260 angle_cmp((&a.du, &a.dv), (&b.du, &b.dv)).then_with(|| a.piece.cmp(&b.piece))
261 });
262
263 // Regularization: within each coincident-direction group, cancel
264 // opposite-traversal pairs (discard both — infinitely thin material).
265 let m = incidents.len();
266 let mut cancelled = vec![false; m];
267 let mut i = 0;
268 while i < m {
269 let mut j = i;
270 while j + 1 < m
271 && angle_cmp(
272 (&incidents[i].du, &incidents[i].dv),
273 (&incidents[j + 1].du, &incidents[j + 1].dv),
274 ) == Ordering::Equal
275 {
276 j += 1;
277 }
278 // Group [i, j]: pair up +w with -w traversals.
279 let mut fwd: Vec<usize> = (i..=j).filter(|&x| incidents[x].forward).collect();
280 let mut bwd: Vec<usize> = (i..=j).filter(|&x| !incidents[x].forward).collect();
281 while let (Some(f), Some(bk)) = (fwd.pop(), bwd.pop()) {
282 cancelled[f] = true;
283 cancelled[bk] = true;
284 discarded[incidents[f].piece] = true;
285 discarded[incidents[bk].piece] = true;
286 }
287 i = j + 1;
288 }
289
290}
291
292#[cfg(test)]
293#[path = "classify_tests.rs"]
294mod tests;