manifold_rust/robust/pairing.rs
1// robust/pairing.rs — Geometrically correct half-edge pairing for the
2// extracted boundary (used by robust/assemble.rs).
3//
4// `cells::extract` can legitimately emit a boundary that touches itself
5// along an edge: the solid occupies two separate wedges around one
6// arrangement edge, so that undirected vertex-id edge carries four (in
7// general 2k) half-edges. `ManifoldImpl::create_halfedges` pairs half-edges
8// by vertex ids alone, which on such an edge picks an arbitrary
9// forward/backward partner. A crosslinked guess fuses the two geometric
10// fans into one combinatorial orbit, and `edge_op::cleanup_topology` then
11// "repairs" the fused orbit by repointing corners of unrelated faces onto
12// another vertex's position — moving geometry and destroying volume (on
13// Thingi10K #301921 ∪ its rotated copy, one `dedupe_edge` call cost 7% of
14// the model).
15//
16// This module removes the guess. It radially sorts the half-faces around
17// every such edge with the same filtered orient3d machinery the cell
18// complex uses (`cells::radial_fan`), pairs each half-edge with the
19// radially adjacent one bounding the *same solid wedge*, and reports the
20// split of each pinched vertex into one copy per fan that the pairing
21// implies. The assembly emits those copies as distinct output vertices, so
22// every undirected edge carries exactly two half-edges again and the
23// import's id-based pairing reproduces the geometric one — leaving
24// `cleanup_topology` with nothing to repair.
25//
26// An accepted plan splits *every* pinched vertex orbit in the mesh, not
27// only the ones a multi-edge fan touches: the copy index comes from the
28// connected components of the corner graph induced by the whole pairing, so
29// a vertex whose star is two cones meeting at a single point, far from any
30// multi-edge, is separated as well.
31//
32// Only meshes that actually carry such an edge take this path; everything
33// else keeps using the generic MeshGL import unchanged. Anything the fan
34// cannot certify (odd fans, coincident radial directions, apexes on the
35// edge axis, traversals that do not alternate, a split that fails to
36// separate the fans) yields `None`, and the caller falls back to that same
37// generic import.
38
39// Fx hashing (unseeded): every map here is probe-only — fan-copy ordinals are
40// assigned walking half-edges in index order, and the edge-count table is only
41// ever looked up — so hash order cannot reach the split plan.
42use rustc_hash::FxHashMap as HashMap;
43
44use crate::disjoint_sets::DisjointSets;
45
46use super::cells::{radial_fan, VertTables};
47use super::intersection_graph::{edge_key, EdgeKey};
48
49/// Half-face record consumed by [`radial_fan`]: (edge key, half-edge index,
50/// forward traversal, apex vertex id).
51type Half = (EdgeKey, usize, bool, u32);
52
53/// Per-corner copy index for each triangle corner (`3 * tri + corner`).
54/// `0` for every corner of an unpinched vertex.
55pub type SplitPlan = Vec<u32>;
56
57/// One arrangement edge carrying more than two half-edges.
58struct Fan {
59 key: EdgeKey,
60 halfs: Vec<Half>,
61 /// Pair across the empty wedges instead of the solid ones. Only set when
62 /// the geometric pairing leaves the fans inseparable — see
63 /// [`plan_vertex_splits`].
64 flip: bool,
65}
66
67/// Plan the vertex splits that make id-based half-edge pairing reproduce the
68/// geometric pairing of `tris` (triangles of interned vertex ids, wound
69/// outward).
70///
71/// Returns `None` when no split is needed (no edge carries more than two
72/// half-edges — the overwhelmingly common case, which must stay on the
73/// untouched import path) and also when the geometry cannot be certified,
74/// so callers get today's behavior rather than a guess.
75///
76/// The geometric pairing ([`pair_fan`]) is preferred everywhere. It is not
77/// always expressible through vertex ids: when the two sheets meeting along
78/// an edge reconnect around *both* of its endpoints, no vertex split can
79/// separate them, and leaving the duplicate id-edge in place hands the mesh
80/// to `dedupe_edge`, which splits the pinched *start* vertex onto the end
81/// vertex's position (upstream `Impl::DedupeEdge`) and wrecks the geometry.
82/// Such a fan falls back to the other radially adjacent pairing — also a
83/// closed, consistently oriented surface over the identical triangles,
84/// differing only in which sheets are joined — and the plan is rejected
85/// outright if even that leaves the edge duplicated.
86pub fn plan_vertex_splits(tris: &[[u32; 3]], vt: VertTables) -> Option<SplitPlan> {
87 let mut incident: Vec<Half> = Vec::with_capacity(3 * tris.len());
88 for (t, vi) in tris.iter().enumerate() {
89 for c in 0..3 {
90 let (a, b) = (vi[c], vi[(c + 1) % 3]);
91 if a == b {
92 return None; // degenerate corner: no radial direction
93 }
94 incident.push((edge_key(a, b), 3 * t + c, a < b, vi[(c + 2) % 3]));
95 }
96 }
97 incident.sort_unstable();
98
99 // Partner half-edge of every half-edge on an ordinary (two half-edge)
100 // arrangement edge; the fans fill in the rest on each attempt.
101 let mut base = vec![usize::MAX; 3 * tris.len()];
102 let mut fans: Vec<Fan> = Vec::new();
103 let mut at = 0;
104 while at < incident.len() {
105 let key = incident[at].0;
106 let mut end = at + 1;
107 while end < incident.len() && incident[end].0 == key {
108 end += 1;
109 }
110 let raw = &incident[at..end];
111 at = end;
112 if raw.len() == 2 {
113 // An ordinary edge: the two half-edges must run opposite ways.
114 if raw[0].2 == raw[1].2 {
115 return None;
116 }
117 base[raw[0].1] = raw[1].1;
118 base[raw[1].1] = raw[0].1;
119 continue;
120 }
121 if raw.len() % 2 != 0 {
122 return None; // a boundary or odd fan: not a closed surface
123 }
124 fans.push(Fan {
125 key,
126 halfs: raw.to_vec(),
127 flip: false,
128 });
129 }
130 if fans.is_empty() {
131 return None;
132 }
133
134 // Whole attempts (one radial pass over every fan plus one split) are
135 // bounded by a small constant rather than by the fan count: the fan
136 // predicates are the expensive part of assembly, and a mesh that needs
137 // more rounds than this is better served by degrading to the untouched
138 // import path than by paying O(fans) passes to maybe salvage it.
139 const MAX_ATTEMPTS: usize = 4;
140 let mut budget = MAX_ATTEMPTS;
141 loop {
142 let Some(a) = attempt(tris, &base, &fans, vt, &mut budget) else {
143 return None;
144 };
145 if a.separated.iter().all(|&s| s) {
146 return settle(tris, &base, &mut fans, vt, &mut budget, a);
147 }
148 // Flip the fans the geometric pairing could not separate. Flips
149 // latch within this loop — a fan flipped to unblock one round is
150 // not reconsidered here even if a later round would have separated
151 // it geometrically — which is what `settle` exists to undo.
152 let mut progress = false;
153 for (i, fan) in fans.iter_mut().enumerate() {
154 if !a.separated[i] && !fan.flip {
155 fan.flip = true;
156 progress = true;
157 }
158 }
159 if !progress {
160 return None;
161 }
162 }
163}
164
165/// The outcome of one pairing pass: the split it implies and which fans it
166/// managed to separate.
167struct Attempt {
168 plan: SplitPlan,
169 separated: Vec<bool>,
170 /// Every edge of the split mesh is an ordinary one (exactly one
171 /// half-edge each way) — the condition the caller must hand the import.
172 edges_ok: bool,
173}
174
175/// One pairing pass over every fan plus the split it implies. Consumes one
176/// unit of `budget`; `None` once that runs out or when a fan cannot be
177/// certified.
178fn attempt(
179 tris: &[[u32; 3]],
180 base: &[usize],
181 fans: &[Fan],
182 vt: VertTables,
183 budget: &mut usize,
184) -> Option<Attempt> {
185 if *budget == 0 {
186 return None;
187 }
188 *budget -= 1;
189 let mut partner = base.to_vec();
190 for fan in fans {
191 pair_fan(fan, vt, &mut partner)?;
192 }
193 let plan = split_from_partners(tris, &partner);
194 let counts = split_edge_counts(tris, &plan)?;
195 Some(Attempt {
196 separated: fans
197 .iter()
198 .map(|fan| fan_separated(fan, tris, &plan, &counts))
199 .collect(),
200 edges_ok: counts.values().all(|&(f, b)| f == 1 && b == 1),
201 plan,
202 })
203}
204
205/// Drop flips that are no longer needed.
206///
207/// A fan flipped early can owe its flip to another fan that has since been
208/// flipped too, so re-test each flipped fan geometrically and keep the
209/// geometric pairing wherever it now separates. Best effort within the
210/// remaining attempt budget: an un-flip that cannot be re-tested stays.
211fn settle(
212 tris: &[[u32; 3]],
213 base: &[usize],
214 fans: &mut [Fan],
215 vt: VertTables,
216 budget: &mut usize,
217 accepted: Attempt,
218) -> Option<SplitPlan> {
219 let mut accepted = accepted;
220 for i in 0..fans.len() {
221 if !fans[i].flip || *budget == 0 {
222 continue;
223 }
224 fans[i].flip = false;
225 match attempt(tris, base, fans, vt, budget) {
226 Some(a) if a.separated.iter().all(|&s| s) => accepted = a,
227 _ => fans[i].flip = true,
228 }
229 }
230 accepted.edges_ok.then_some(accepted.plan)
231}
232
233/// Pair the half-edges of one fan across their shared solid wedges.
234///
235/// [`radial_fan`] orders the half-faces counter-clockwise about the
236/// canonical edge direction `k0 → k1`. A forward-traversing face is wound
237/// `(k0, k1, apex)`, so its normal sits 90° counter-clockwise of its apex
238/// direction (`cells::ccw_side`), and the extracted boundary's normals point
239/// *away* from material (`cells::extract`) — so a forward face has material
240/// in the wedge clockwise of it and a backward face in the wedge
241/// counter-clockwise of it. Traversals therefore alternate around the fan,
242/// and the wedge between radial positions `i` and `i + 1` is solid exactly
243/// when face `i` is backward. Those two faces bound one solid wedge, which
244/// makes them the adjacent pair of the surface enclosing it — for two cubes
245/// meeting along an edge, exactly the two faces of the same cube.
246///
247/// `fan.flip` pairs across the empty wedges instead; see
248/// [`plan_vertex_splits`] for when that is used.
249fn pair_fan(fan: &Fan, vt: VertTables, partner: &mut [usize]) -> Option<()> {
250 let (incs, groups) = radial_fan(fan.key.0, fan.key.1, &fan.halfs, vt)?;
251 // Every half-face must survive the fan (none on the edge axis) and hold
252 // its own radial direction (no coincident pair to disambiguate).
253 if incs.len() != fan.halfs.len() || groups.len() != incs.len() {
254 return None;
255 }
256 let n = incs.len();
257 for i in 0..n {
258 if incs[i].forward != fan.flip {
259 continue;
260 }
261 let j = (i + 1) % n;
262 if incs[j].forward == fan.flip {
263 return None; // traversals must alternate around the fan
264 }
265 partner[incs[i].id] = incs[j].id;
266 partner[incs[j].id] = incs[i].id;
267 }
268 if fan.halfs.iter().any(|h| partner[h.1] == usize::MAX) {
269 return None;
270 }
271 Some(())
272}
273
274/// Corners that the pairing keeps in one fan become one output vertex.
275fn split_from_partners(tris: &[[u32; 3]], partner: &[usize]) -> SplitPlan {
276 let n = 3 * tris.len();
277 let ds = DisjointSets::new(n.max(1) as u32);
278 for h in 0..n {
279 let p = partner[h];
280 if p == usize::MAX || p < h {
281 continue;
282 }
283 // `h` runs a→b inside its triangle and `p` runs b→a inside its own,
284 // so they meet at a via corners (h, p+1) and at b via (h+1, p).
285 let (ht, hc) = (h / 3, h % 3);
286 let (pt, pc) = (p / 3, p % 3);
287 ds.unite(h as u32, (3 * pt + (pc + 1) % 3) as u32);
288 ds.unite((3 * ht + (hc + 1) % 3) as u32, p as u32);
289 }
290
291 let mut ordinal: HashMap<u32, u32> = HashMap::default();
292 let mut next: HashMap<u32, u32> = HashMap::default();
293 let mut plan = vec![0u32; n];
294 for h in 0..n {
295 let root = ds.find(h as u32);
296 let vid = tris[h / 3][h % 3];
297 plan[h] = *ordinal.entry(root).or_insert_with(|| {
298 let slot = next.entry(vid).or_insert(0);
299 let id = *slot;
300 *slot += 1;
301 id
302 });
303 }
304 plan
305}
306
307/// Identity of one corner after splitting: its vertex id and fan copy.
308#[inline]
309fn split_vert(tris: &[[u32; 3]], plan: &SplitPlan, h: usize) -> (u32, u32) {
310 (tris[h / 3][h % 3], plan[h])
311}
312
313/// Forward/backward half-edge counts per undirected edge of the split mesh.
314/// `None` if a corner pair collapsed, which would make the counts
315/// meaningless. That is belt and braces: `plan_vertex_splits` already
316/// rejects a triangle with a repeated vertex id up front, and splitting only
317/// refines vertex identity, so it cannot merge two distinct corners.
318fn split_edge_counts(
319 tris: &[[u32; 3]],
320 plan: &SplitPlan,
321) -> Option<HashMap<((u32, u32), (u32, u32)), (u32, u32)>> {
322 let mut counts = HashMap::default();
323 for t in 0..tris.len() {
324 for c in 0..3 {
325 let a = split_vert(tris, plan, 3 * t + c);
326 let b = split_vert(tris, plan, 3 * t + (c + 1) % 3);
327 if a == b {
328 return None;
329 }
330 let e = counts.entry(if a < b { (a, b) } else { (b, a) }).or_insert((0, 0));
331 if a < b {
332 e.0 += 1;
333 } else {
334 e.1 += 1;
335 }
336 }
337 }
338 Some(counts)
339}
340
341/// Did the split actually separate this fan into ordinary edges?
342fn fan_separated(
343 fan: &Fan,
344 tris: &[[u32; 3]],
345 plan: &SplitPlan,
346 counts: &HashMap<((u32, u32), (u32, u32)), (u32, u32)>,
347) -> bool {
348 fan.halfs.iter().all(|&(_, h, _, _)| {
349 let a = split_vert(tris, plan, h);
350 let b = split_vert(tris, plan, 3 * (h / 3) + (h % 3 + 1) % 3);
351 counts.get(&if a < b { (a, b) } else { (b, a) }) == Some(&(1, 1))
352 })
353}
354
355#[cfg(test)]
356#[path = "pairing_tests.rs"]
357mod tests;