Skip to main content

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;