Skip to main content

brep_kernel/healing/
sew.rs

1//! Standalone heal/sew — Golovanov's "sewing": assemble a valid oriented
2//! shell out of faces whose boundary representations were built independently
3//! (imported shells, detached face groups, healing after hand edits).
4//!
5//! The boolean assembler and the offset pipeline each carry private sewing
6//! passes specialised to their own invariants (`sew_coincident_one_use_edges`
7//! requires already-opposed traversals; the offset welds know their rims).
8//! This module is the GENERAL entry: it pairs coincident one-use boundary
9//! edges by geometry alone — open chains by matched endpoints + locus
10//! agreement, closed rims by mutual locus agreement — WITHOUT any orientation
11//! precondition, because a face soup's components may be arbitrarily flipped.
12//! Orientation is repaired after pairing: coedge-direction coherence is
13//! propagated across the now-shared edges and the whole solid is flipped
14//! outward by signed volume.
15//!
16//! Sewing is BEST-EFFORT and honest: pairs that cannot be joined within
17//! tolerance stay open and are counted in the report; nothing is force-welded.
18
19use crate::mass_properties::solid_signed_volume;
20use crate::offset_shell::{flip_all_faces, orient_open_solid_faces};
21use crate::topology::{BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, ShellRecord, VertexRecord};
22use crate::{build_pcurve_on_surface, project_point_to_curve, NurbsCurve, Vec3};
23use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
24use serde::{Deserialize, Serialize};
25
26#[derive(Clone, Debug, Serialize, Deserialize)]
27pub struct SewReport {
28    pub edges_sewn: usize,
29    pub shells_merged: usize,
30    pub open_edges_before: usize,
31    pub open_edges_after: usize,
32    pub oriented_outward: bool,
33    pub issues: Vec<String>,
34}
35
36fn one_use_edge_ids(solid: &BrepSolid) -> HashSet<u64> {
37    let mut counts = HashMap::<u64, usize>::default();
38    for coedge in solid
39        .shells
40        .iter()
41        .flat_map(|shell| &shell.faces)
42        .flat_map(|face| &face.loops)
43        .flat_map(|loop_record| &loop_record.coedges)
44    {
45        *counts.entry(coedge.edge_id).or_default() += 1;
46    }
47    counts
48        .into_iter()
49        .filter(|(_, count)| *count == 1)
50        .map(|(id, _)| id)
51        .collect()
52}
53
54fn curve_point(edge: &EdgeRecord, fraction: f64) -> Result<Vec3, String> {
55    edge.curve
56        .evaluate(edge.t0 + (edge.t1 - edge.t0) * fraction)
57}
58
59/// Worst distance from sampled points of `piece` to the locus of `carrier`.
60fn locus_deviation(
61    piece: &EdgeRecord,
62    carrier: &EdgeRecord,
63    samples: usize,
64) -> Result<f64, String> {
65    let mut worst = 0.0f64;
66    for index in 0..=samples {
67        let point = curve_point(piece, index as f64 / samples as f64)?;
68        worst = worst.max(project_point_to_curve(&carrier.curve, point)?.distance);
69    }
70    Ok(worst)
71}
72
73/// Pointwise agreement at matched fractions — the two edges are not merely
74/// the same locus but the SAME parametrization (the duplicated-edge case),
75/// letting a rebind keep the existing pcurve exactly.
76fn same_parametrization(
77    first: &EdgeRecord,
78    second: &EdgeRecord,
79    tolerance: f64,
80) -> Result<bool, String> {
81    for index in 0..=8 {
82        let fraction = index as f64 / 8.0;
83        if curve_point(first, fraction)?
84            .sub(curve_point(second, fraction)?)
85            .length()
86            > tolerance
87        {
88            return Ok(false);
89        }
90    }
91    Ok(true)
92}
93
94/// The 3D walk this coedge's loop takes along its edge, sampled from its
95/// pcurve through its face surface. Fractions 0 and 0.35: an interior second
96/// sample avoids the antipodal-projection ambiguity a closed rim has at 0.5.
97fn walk_points(face: &FaceRecord, coedge: &CoedgeRecord) -> Result<[Vec3; 2], String> {
98    let [start, end] = coedge.pcurve.domain()?;
99    let sample = |fraction: f64| -> Result<Vec3, String> {
100        let uv = coedge.pcurve.evaluate(start + (end - start) * fraction)?;
101        face.surface.evaluate(uv.x, uv.y)
102    };
103    Ok([sample(0.0)?, sample(0.35)?])
104}
105
106/// One planned coedge rebind, resolved before any mutation so a failed plan
107/// (an unprojectable pcurve, say) leaves the pair untouched and open.
108struct RebindPatch {
109    shell: usize,
110    face: usize,
111    loop_index: usize,
112    coedge: usize,
113    forward: bool,
114    pcurve: Option<NurbsCurve>,
115}
116
117/// Plan the rebind of every coedge referencing `remove` onto `keep`. The
118/// coedge's `forward` is derived from the 3D walk its existing pcurve
119/// produces (geometry is authoritative — components may be flipped), the
120/// pcurve is kept when the parametrizations agree and refit otherwise.
121fn plan_rebind(
122    solid: &BrepSolid,
123    keep: &EdgeRecord,
124    remove: &EdgeRecord,
125    tolerance: f64,
126) -> Result<Vec<RebindPatch>, String> {
127    let identical = same_parametrization(keep, remove, tolerance)?;
128    let closed = keep.start_vertex_id == keep.end_vertex_id;
129    let period = keep.t1 - keep.t0;
130    let mut patches = Vec::new();
131    for (shell_index, shell) in solid.shells.iter().enumerate() {
132        for (face_index, face) in shell.faces.iter().enumerate() {
133            for (loop_index, loop_record) in face.loops.iter().enumerate() {
134                for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
135                    if coedge.edge_id != remove.id {
136                        continue;
137                    }
138                    let [walk_start, walk_next] = walk_points(face, coedge)?;
139                    let t_start = project_point_to_curve(&keep.curve, walk_start)?.u;
140                    let t_next = project_point_to_curve(&keep.curve, walk_next)?.u;
141                    let forward = if closed {
142                        let mut delta = t_next - t_start;
143                        while delta > period * 0.5 {
144                            delta -= period;
145                        }
146                        while delta < -period * 0.5 {
147                            delta += period;
148                        }
149                        delta > 0.0
150                    } else {
151                        t_next > t_start
152                    };
153                    let pcurve = if identical {
154                        None
155                    } else {
156                        let traversal = if forward {
157                            keep.curve.clone()
158                        } else {
159                            keep.curve.reversed()?
160                        };
161                        Some(build_pcurve_on_surface(&face.surface, &traversal)?)
162                    };
163                    patches.push(RebindPatch {
164                        shell: shell_index,
165                        face: face_index,
166                        loop_index,
167                        coedge: coedge_index,
168                        forward,
169                        pcurve,
170                    });
171                }
172            }
173        }
174    }
175    Ok(patches)
176}
177
178fn apply_rebind(
179    solid: &mut BrepSolid,
180    keep: &EdgeRecord,
181    remove: &EdgeRecord,
182    patches: Vec<RebindPatch>,
183    tolerance: f64,
184) {
185    for patch in patches {
186        let coedge = &mut solid.shells[patch.shell].faces[patch.face].loops[patch.loop_index]
187            .coedges[patch.coedge];
188        coedge.edge_id = keep.id;
189        coedge.forward = patch.forward;
190        if let Some(pcurve) = patch.pcurve {
191            coedge.pcurve = pcurve;
192        }
193    }
194    solid.edges.retain(|edge| edge.id != remove.id);
195    // Weld the removed edge's endpoint vertices into the kept edge's, so
196    // OTHER edges of the removed component (a loop mixes sewn rim edges with
197    // still-duplicate interior edges) chain through the shared vertices.
198    // Only coincident endpoints weld — a rotated closed rim keeps its own
199    // seam vertex.
200    let point_of = |vertex_id: u64| {
201        solid
202            .vertices
203            .iter()
204            .find(|vertex| vertex.id == vertex_id)
205            .map(|vertex| vertex.point)
206    };
207    let mut welds = Vec::<(u64, u64)>::new();
208    for from in [remove.start_vertex_id, remove.end_vertex_id] {
209        let Some(from_point) = point_of(from) else {
210            continue;
211        };
212        let target = [keep.start_vertex_id, keep.end_vertex_id]
213            .into_iter()
214            .filter_map(|candidate| {
215                point_of(candidate).map(|point| (candidate, point.sub(from_point).length()))
216            })
217            .min_by(|first, second| first.1.total_cmp(&second.1));
218        if let Some((to, distance)) = target {
219            if distance <= tolerance && from != to {
220                welds.push((from, to));
221            }
222        }
223    }
224    for (from, to) in welds {
225        for edge in &mut solid.edges {
226            if edge.start_vertex_id == from {
227                edge.start_vertex_id = to;
228            }
229            if edge.end_vertex_id == from {
230                edge.end_vertex_id = to;
231            }
232        }
233    }
234}
235
236/// Decide which of an accepted pair is KEPT and which is REMOVED.
237///
238/// For an open chain, and for a closed rim whose two copies share a seam
239/// point, either direction works and the scan's own order stands — `first` is
240/// kept, exactly as before.
241///
242/// A seam-ROTATED closed rim is different, and the difference is not cosmetic.
243/// The removed rim takes its seam vertex out of the solid with it, so every
244/// OTHER edge that chained through that vertex is left chaining through
245/// nothing. `apply_rebind` deliberately does not drag it onto the kept rim's
246/// seam — a rotated closed rim keeps its own seam vertex, because moving it
247/// would pull a real edge endpoint an arbitrary arc round the circle, off its
248/// own curve — so the tear is not repaired, it is inherited. Sewn that way a
249/// cylinder comes back with `edges_sewn: 1, open_edges_after: 0` over a wall
250/// loop torn at the seam: a report that reads like success.
251///
252/// The direction that works is the one whose REMOVED rim's seam vertex bounds
253/// no other edge. When neither qualifies — both seams anchor real geometry —
254/// no rebind keeps both loops closed, and the pair is left OPEN and counted
255/// rather than sewn into a solid whose own report contradicts its issues.
256fn orient_pair<'a>(
257    solid: &BrepSolid,
258    first: &'a EdgeRecord,
259    second: &'a EdgeRecord,
260    points: &HashMap<u64, Vec3>,
261    tolerance: f64,
262) -> Option<(&'a EdgeRecord, &'a EdgeRecord)> {
263    // The caller has already rejected mismatched closedness, so testing
264    // `first` tests both.
265    if first.start_vertex_id != first.end_vertex_id {
266        return Some((first, second));
267    }
268    let (Some(&first_seam), Some(&second_seam)) = (
269        points.get(&first.start_vertex_id),
270        points.get(&second.start_vertex_id),
271    ) else {
272        return Some((first, second));
273    };
274    if first_seam.sub(second_seam).length() <= tolerance {
275        // Seams coincide: `apply_rebind`'s weld joins them and every loop
276        // through either one still chains. Order is free.
277        return Some((first, second));
278    }
279    let seam_is_exclusive = |rim: &EdgeRecord| {
280        !solid.edges.iter().any(|edge| {
281            edge.id != rim.id
282                && (edge.start_vertex_id == rim.start_vertex_id
283                    || edge.end_vertex_id == rim.start_vertex_id)
284        })
285    };
286    if seam_is_exclusive(second) {
287        Some((first, second))
288    } else if seam_is_exclusive(first) {
289        Some((second, first))
290    } else {
291        None
292    }
293}
294
295/// Merge shells that now share an edge into single shells (union-find).
296fn merge_connected_shells(solid: &mut BrepSolid) -> usize {
297    let mut owner_of_edge = HashMap::<u64, usize>::default();
298    let mut parent = (0..solid.shells.len()).collect::<Vec<_>>();
299    fn root(parent: &mut [usize], index: usize) -> usize {
300        if parent[index] != index {
301            parent[index] = root(parent, parent[index]);
302        }
303        parent[index]
304    }
305    for (shell_index, shell) in solid.shells.iter().enumerate() {
306        for coedge in shell
307            .faces
308            .iter()
309            .flat_map(|face| &face.loops)
310            .flat_map(|loop_record| &loop_record.coedges)
311        {
312            if let Some(&other) = owner_of_edge.get(&coedge.edge_id) {
313                let first = root(&mut parent, other);
314                let second = root(&mut parent, shell_index);
315                if first != second {
316                    parent[second] = first;
317                }
318            } else {
319                owner_of_edge.insert(coedge.edge_id, shell_index);
320            }
321        }
322    }
323    let before = solid.shells.len();
324    let original = std::mem::take(&mut solid.shells);
325    let mut merged = Vec::<ShellRecord>::new();
326    let mut group_of = HashMap::<usize, usize>::default();
327    for (index, shell) in original.into_iter().enumerate() {
328        let group = root(&mut parent, index);
329        if let Some(&target) = group_of.get(&group) {
330            merged[target].faces.extend(shell.faces);
331        } else {
332            group_of.insert(group, merged.len());
333            merged.push(shell);
334        }
335    }
336    solid.shells = merged;
337    before - solid.shells.len()
338}
339
340/// Which `second` candidates the pair search offers for a given `first`.
341///
342/// The two strategies feed the SAME loop body in the SAME ascending order, so
343/// "the prefilter is correct" reduces to one claim: the offered list is a
344/// SUPERSET of the candidates that could pass. Nothing else in the search can
345/// differ, which is what makes the parity test (`sew_prefilter_parity`) a real
346/// proof rather than a spot check — first-match-wins makes iteration order
347/// part of the RESULT, not just the cost.
348#[derive(Clone, Copy, PartialEq, Eq, Debug)]
349enum PairSearch {
350    /// Production: neighbourhood query for open chains, closed-rim shortlist
351    /// for closed rims.
352    Hashed,
353    /// The reference brute-force scan the prefilter must reproduce exactly.
354    #[cfg_attr(not(test), allow(dead_code))]
355    Exhaustive,
356}
357
358/// The integer cell of `point`, or `None` when the coordinates cannot be
359/// bucketed at all (non-finite, or so large the index overflows the i64 key).
360/// A `None` anywhere disables the prefilter for the whole pass rather than
361/// silently dropping a candidate — exact parity with the exhaustive scan is
362/// the contract, and one lost candidate would break it.
363fn grid_cell(point: Vec3, cell: f64) -> Option<[i64; 3]> {
364    let mut key = [0i64; 3];
365    for (slot, value) in key.iter_mut().zip([point.x, point.y, point.z]) {
366        if !value.is_finite() {
367            return None;
368        }
369        let scaled = (value / cell).floor();
370        // 9e15 keeps the cast well inside i64 and inside f64's exact-integer
371        // range, so `as i64` is a faithful conversion, not a saturating one.
372        if !scaled.is_finite() || scaled.abs() > 9.0e15 {
373            return None;
374        }
375        *slot = scaled as i64;
376    }
377    Some(key)
378}
379
380/// Uniform spatial hash over the ENDPOINTS of the open one-use candidates —
381/// the prefilter that turns the pair search from a full rescan per accepted
382/// pair into a neighbourhood query.
383///
384/// Cells are `2·tolerance` wide, not `tolerance`. A partner endpoint is at
385/// most `tolerance = cell/2` away, i.e. at most HALF a cell, so it lands in
386/// this cell or an immediate neighbour with a full half-cell of slack — slack
387/// that absorbs the rounding of the `point/cell` division. At `cell =
388/// tolerance` two points exactly `tolerance` apart can straddle two cell
389/// boundaries and the 3×3×3 block would still hold them, but with zero margin
390/// for the division's last bit; the factor of two costs one extra ring of
391/// mostly-empty buckets and buys certainty.
392struct EndpointGrid {
393    cell: f64,
394    buckets: HashMap<[i64; 3], Vec<usize>>,
395}
396
397impl EndpointGrid {
398    /// Bucket every open candidate under BOTH of its endpoints.
399    ///
400    /// Closed rims are deliberately absent. They are paired by mutual locus
401    /// agreement alone — `sew_solid`'s closed branch never looks at a vertex —
402    /// and a seam-ROTATED duplicate rim shares its partner's locus while
403    /// sharing no point position at all, so any point-keyed bucket (midpoint
404    /// included) would drop exactly the pair the closed lane exists to catch.
405    /// Closed rims therefore keep a linear shortlist; see `offer_seconds`.
406    ///
407    /// Candidates whose vertex records are missing are absent too: the
408    /// exhaustive scan `continue`s such a pair outright, so dropping it here
409    /// changes nothing.
410    fn build(endpoints: &[Option<[Vec3; 2]>], closed: &[bool], tolerance: f64) -> Option<Self> {
411        let cell = 2.0 * tolerance;
412        if !(cell.is_finite() && cell > 0.0) {
413            return None;
414        }
415        let mut buckets = HashMap::<[i64; 3], Vec<usize>>::default();
416        for (position, ends) in endpoints.iter().enumerate() {
417            if closed[position] {
418                continue;
419            }
420            let Some(ends) = ends else {
421                continue;
422            };
423            for point in ends {
424                buckets
425                    .entry(grid_cell(*point, cell)?)
426                    .or_default()
427                    .push(position);
428            }
429        }
430        Some(EndpointGrid { cell, buckets })
431    }
432
433    /// Every open candidate with an endpoint in the 3×3×3 block around
434    /// `point`, ascending and deduplicated. A superset of "has an endpoint
435    /// within tolerance of `point`", which is all the pair search needs.
436    /// `false` means the point itself is unbucketable and the caller must not
437    /// trust the (empty) answer.
438    fn near(&self, point: Vec3, out: &mut Vec<usize>) -> bool {
439        out.clear();
440        let Some(centre) = grid_cell(point, self.cell) else {
441            return false;
442        };
443        for dx in -1..=1 {
444            for dy in -1..=1 {
445                for dz in -1..=1 {
446                    if let Some(bucket) =
447                        self.buckets
448                            .get(&[centre[0] + dx, centre[1] + dy, centre[2] + dz])
449                    {
450                        out.extend_from_slice(bucket);
451                    }
452                }
453            }
454        }
455        out.sort_unstable();
456        out.dedup();
457        true
458    }
459}
460
461/// Fill `offered` with the `second` positions to test against
462/// `candidates[first_position]`, ASCENDING — the order the exhaustive scan
463/// used, which first-match-wins makes load-bearing.
464///
465/// Three lanes, each a provable superset of what can pass:
466/// - **No grid** (prefilter disabled, or `PairSearch::Exhaustive`): every
467///   later position, i.e. the original scan verbatim.
468/// - **Closed `first`**: the later CLOSED positions. An open `second` is
469///   rejected by the `first_closed != second_closed` test before any
470///   geometry is touched, so shortlisting them away changes nothing — but
471///   nothing finer is available, because closed-rim pairing reads the locus
472///   and not any point (see `EndpointGrid::build`). Closed rims stay
473///   quadratic in their own (small) count; that is honest, not hidden.
474/// - **Open `first`**: one neighbourhood query around `first`'s START point.
475///   An open pair is accepted only when `first`'s start matches `second`'s
476///   start (direct) or `second`'s end (reversed), so EVERY pair that can pass
477///   has an endpoint within tolerance of that single point, and one query is
478///   a complete superset. A `first` whose vertex records are missing offers
479///   nothing, matching the exhaustive scan's `continue`.
480#[allow(clippy::too_many_arguments)]
481fn offer_seconds(
482    grid: Option<&EndpointGrid>,
483    first_position: usize,
484    first_closed: bool,
485    endpoints: &[Option<[Vec3; 2]>],
486    closed_positions: &[usize],
487    candidate_count: usize,
488    neighbours: &mut Vec<usize>,
489    offered: &mut Vec<usize>,
490) {
491    offered.clear();
492    let Some(grid) = grid else {
493        offered.extend(first_position + 1..candidate_count);
494        return;
495    };
496    if first_closed {
497        offered.extend(
498            closed_positions
499                .iter()
500                .copied()
501                .filter(|&position| position > first_position),
502        );
503        return;
504    }
505    let Some([start, _]) = endpoints[first_position] else {
506        return;
507    };
508    if grid.near(start, neighbours) {
509        offered.extend(
510            neighbours
511                .iter()
512                .copied()
513                .filter(|&position| position > first_position),
514        );
515    }
516}
517
518/// Best-effort sew of a solid's open boundary edges.
519///
520/// Pairs coincident one-use edges (open chains by matched endpoints + locus
521/// agreement, closed rims by mutual locus agreement) and rebinds each pair to
522/// one shared edge, merging the shells they join. Orientation carries NO
523/// precondition: after pairing, coedge-direction coherence is propagated
524/// across the shared edges and the result is flipped outward by signed
525/// volume. Unsewable gaps stay open and are reported, never force-welded.
526pub fn sew_solid(solid: &BrepSolid, tolerance: f64) -> Result<(BrepSolid, SewReport), String> {
527    let (sewn, report, _examined) = sew_solid_with_search(solid, tolerance, PairSearch::Hashed)?;
528    Ok((sewn, report))
529}
530
531/// [`sew_solid`] with the pair-search strategy exposed, plus the number of
532/// (first, second) pairs the search actually examined.
533///
534/// The count is what the scaling smoke asserts on: it is a deterministic
535/// function of the input, where wall time is not, so a band set from it is a
536/// real bound rather than a machine-speed lottery.
537fn sew_solid_with_search(
538    solid: &BrepSolid,
539    tolerance: f64,
540    search: PairSearch,
541) -> Result<(BrepSolid, SewReport, usize), String> {
542    if !(tolerance.is_finite() && tolerance > 0.0) {
543        return Err("sew_solid: tolerance must be positive".into());
544    }
545    let mut result = solid.clone();
546    let open_edges_before = one_use_edge_ids(&result).len();
547    let mut edges_sewn = 0usize;
548    let mut pairs_examined = 0usize;
549    // Pairs whose rebind plan failed (unprojectable pcurve) — left open
550    // rather than retried forever.
551    //
552    // Keyed UNORDERED, because `orient_pair` may swap which of the two is kept
553    // and which is removed. Keyed by (keep, remove) the block would be looked
554    // up under (first, second), miss on a swapped pair, and the scan would
555    // re-offer it, re-swap it, and fail to plan it again on every outer
556    // iteration — nothing having been mutated in between, that is a hang, not
557    // a retry. A pair that cannot be rebound is blocked whichever way round.
558    let mut blocked = HashSet::<(u64, u64)>::default();
559    let unordered = |first: u64, second: u64| (first.min(second), first.max(second));
560    let mut offered = Vec::<usize>::new();
561    let mut neighbours = Vec::<usize>::new();
562    loop {
563        let one_use = one_use_edge_ids(&result);
564        // Candidate POSITIONS into `result.edges`, in edge order — the same
565        // sequence the exhaustive scan walked, without cloning a NURBS curve
566        // per candidate per accepted pair (that clone was the other O(k·n)
567        // term hiding behind the O(k·n²) comparison count).
568        let candidates = result
569            .edges
570            .iter()
571            .enumerate()
572            .filter(|(_, edge)| !edge.degenerate && one_use.contains(&edge.id))
573            .map(|(index, _)| index)
574            .collect::<Vec<_>>();
575        let points = result
576            .vertices
577            .iter()
578            .map(|vertex| (vertex.id, vertex.point))
579            .collect::<HashMap<_, _>>();
580        let closed = candidates
581            .iter()
582            .map(|&index| {
583                let edge = &result.edges[index];
584                edge.start_vertex_id == edge.end_vertex_id
585            })
586            .collect::<Vec<_>>();
587        // Both endpoint positions, or `None` when a vertex record is missing —
588        // exactly the tuple the exhaustive scan destructures, hoisted out of
589        // the inner loop so each candidate's two lookups happen once.
590        let endpoints = candidates
591            .iter()
592            .map(|&index| {
593                let edge = &result.edges[index];
594                match (
595                    points.get(&edge.start_vertex_id),
596                    points.get(&edge.end_vertex_id),
597                ) {
598                    (Some(&start), Some(&end)) => Some([start, end]),
599                    _ => None,
600                }
601            })
602            .collect::<Vec<_>>();
603        let closed_positions = (0..candidates.len())
604            .filter(|&position| closed[position])
605            .collect::<Vec<_>>();
606        let grid = match search {
607            PairSearch::Hashed => EndpointGrid::build(&endpoints, &closed, tolerance),
608            PairSearch::Exhaustive => None,
609        };
610
611        let mut chosen = None;
612        'pairs: for first_position in 0..candidates.len() {
613            let first = &result.edges[candidates[first_position]];
614            let first_closed = closed[first_position];
615            offer_seconds(
616                grid.as_ref(),
617                first_position,
618                first_closed,
619                &endpoints,
620                &closed_positions,
621                candidates.len(),
622                &mut neighbours,
623                &mut offered,
624            );
625            for second_position in offered.iter().copied() {
626                let second = &result.edges[candidates[second_position]];
627                pairs_examined += 1;
628                if blocked.contains(&unordered(first.id, second.id)) {
629                    continue;
630                }
631                if first_closed != closed[second_position] {
632                    continue;
633                }
634                if !first_closed {
635                    let (Some([fs, fe]), Some([ss, se])) =
636                        (endpoints[first_position], endpoints[second_position])
637                    else {
638                        continue;
639                    };
640                    let direct =
641                        fs.sub(ss).length() <= tolerance && fe.sub(se).length() <= tolerance;
642                    let reversed =
643                        fs.sub(se).length() <= tolerance && fe.sub(ss).length() <= tolerance;
644                    if !direct && !reversed {
645                        continue;
646                    }
647                }
648                if locus_deviation(first, second, 8)? > tolerance
649                    || locus_deviation(second, first, 8)? > tolerance
650                {
651                    continue;
652                }
653                let Some((keep, remove)) = orient_pair(&result, first, second, &points, tolerance)
654                else {
655                    // A seam-rotated rim pair with no safe direction: leave it
656                    // open, do not sew a torn loop shut.
657                    continue;
658                };
659                chosen = Some((keep.clone(), remove.clone()));
660                break 'pairs;
661            }
662        }
663        let Some((keep, remove)) = chosen else {
664            break;
665        };
666        match plan_rebind(&result, &keep, &remove, tolerance) {
667            Ok(patches) => {
668                apply_rebind(&mut result, &keep, &remove, patches, tolerance);
669                edges_sewn += 1;
670            }
671            Err(_) => {
672                blocked.insert(unordered(keep.id, remove.id));
673            }
674        }
675    }
676    // Drop vertices only referenced by removed duplicate edges.
677    let used_vertices = result
678        .edges
679        .iter()
680        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
681        .collect::<HashSet<_>>();
682    result
683        .vertices
684        .retain(|vertex| used_vertices.contains(&vertex.id));
685
686    let shells_merged = merge_connected_shells(&mut result);
687    let mut oriented_outward = false;
688    if edges_sewn > 0 {
689        // Components can arrive arbitrarily flipped; make the coedge
690        // directions coherent across the shared edges, then restore outward
691        // normals by signed volume.
692        orient_open_solid_faces(&mut result)?;
693        if let Ok(volume) = solid_signed_volume(&result) {
694            if volume < 0.0 {
695                flip_all_faces(&mut result)?;
696            }
697            oriented_outward = volume.abs() > tolerance * tolerance * tolerance;
698        }
699    }
700    // Re-derive genus from the Euler characteristic (V − E + F − H = 2 − 2g,
701    // H counting inner loops, matching validate()) so validation sees the
702    // merged topology, not the input's bookkeeping. Only when sewing actually
703    // changed the topology — a no-op must not touch a valid genus.
704    if edges_sewn > 0 || shells_merged > 0 {
705        let vertex_count = result.vertices.len() as i64;
706        let edge_count = result.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
707        let face_count = result
708            .shells
709            .iter()
710            .map(|shell| shell.faces.len())
711            .sum::<usize>() as i64;
712        let ring_count = result
713            .shells
714            .iter()
715            .flat_map(|shell| &shell.faces)
716            .map(|face| face.loops.len().saturating_sub(1))
717            .sum::<usize>() as i64;
718        let numerator = 2 - (vertex_count - edge_count + face_count - ring_count);
719        if numerator >= 0 && numerator % 2 == 0 {
720            result.genus = numerator / 2;
721        }
722    }
723    let open_edges_after = one_use_edge_ids(&result).len();
724    let issues = result
725        .validate()
726        .into_iter()
727        .map(|issue| issue.message)
728        .collect();
729    Ok((
730        result,
731        SewReport {
732            edges_sewn,
733            shells_merged,
734            open_edges_before,
735            open_edges_after,
736            oriented_outward,
737            issues,
738        },
739        pairs_examined,
740    ))
741}
742
743// BREP private tests: 5d5f790e24e4b378
744
745/// Split PINCHED vertices — points where two (or more) umbrella fans of
746/// faces meet at a single vertex record. Local manifold checks (edge use
747/// counts, loop closure, orientation) cannot see a pinch; it surfaces only
748/// as an odd Euler characteristic. The link of a manifold boundary vertex is
749/// a single edge-connected fan: union incident edges through every loop
750/// CORNER at the vertex (consecutive coedges meeting there inside one face);
751/// more than one component means distinct fans sharing the record — give
752/// each extra fan its own vertex at the same point and reassign that fan's
753/// edge endpoints. Geometry is untouched; only identity is repaired.
754pub fn split_pinched_vertices(solid: &mut BrepSolid) -> Result<usize, String> {
755    let mut split_count = 0usize;
756    let vertex_ids: Vec<u64> = solid.vertices.iter().map(|vertex| vertex.id).collect();
757    let mut next_id = solid
758        .vertices
759        .iter()
760        .map(|vertex| vertex.id)
761        .chain(solid.edges.iter().map(|edge| edge.id))
762        .max()
763        .unwrap_or(0)
764        + 1;
765    // id -> index maps built once. This routine only mutates edge endpoints
766    // in place and appends vertices, so indices never shift: every lookup
767    // returns the identical live record the previous `.iter().find(id==)`
768    // scans returned. Kills the O(V*coedges*E) nested edge find below.
769    let edge_of_id: HashMap<u64, usize> = solid
770        .edges
771        .iter()
772        .enumerate()
773        .map(|(index, edge)| (edge.id, index))
774        .collect();
775    let vertex_of_id: HashMap<u64, usize> = solid
776        .vertices
777        .iter()
778        .enumerate()
779        .map(|(index, vertex)| (vertex.id, index))
780        .collect();
781    for vertex_id in vertex_ids {
782        // Incident edges (either endpoint; closed and degenerate included so
783        // pole/seam structures stay connected through their corners).
784        let incident: Vec<u64> = solid
785            .edges
786            .iter()
787            .filter(|edge| edge.start_vertex_id == vertex_id || edge.end_vertex_id == vertex_id)
788            .map(|edge| edge.id)
789            .collect();
790        if incident.len() < 4 {
791            // A pinch needs at least two fans of >= 2 edges each.
792            continue;
793        }
794        let index_of: HashMap<u64, usize> = incident
795            .iter()
796            .enumerate()
797            .map(|(index, id)| (*id, index))
798            .collect();
799        let mut parent: Vec<usize> = (0..incident.len()).collect();
800        fn root(parent: &mut [usize], index: usize) -> usize {
801            if parent[index] != index {
802                parent[index] = root(parent, parent[index]);
803            }
804            parent[index]
805        }
806        let edge_end = |edge_id: u64, forward: bool| -> Option<u64> {
807            edge_of_id.get(&edge_id).map(|&index| {
808                let edge = &solid.edges[index];
809                if forward {
810                    edge.end_vertex_id
811                } else {
812                    edge.start_vertex_id
813                }
814            })
815        };
816        for shell in &solid.shells {
817            for face in &shell.faces {
818                for loop_record in &face.loops {
819                    let count = loop_record.coedges.len();
820                    for index in 0..count {
821                        let current = &loop_record.coedges[index];
822                        let next = &loop_record.coedges[(index + 1) % count];
823                        // The corner between current and next sits at
824                        // current's traversal END vertex.
825                        let Some(junction) = edge_end(current.edge_id, current.forward) else {
826                            continue;
827                        };
828                        if junction != vertex_id {
829                            continue;
830                        }
831                        let (Some(&a), Some(&b)) =
832                            (index_of.get(&current.edge_id), index_of.get(&next.edge_id))
833                        else {
834                            continue;
835                        };
836                        let ra = root(&mut parent, a);
837                        let rb = root(&mut parent, b);
838                        if ra != rb {
839                            parent[rb] = ra;
840                        }
841                    }
842                }
843            }
844        }
845        let mut component_of: HashMap<usize, usize> = HashMap::default();
846        let mut components = 0usize;
847        let mut assignment: Vec<usize> = vec![0; incident.len()];
848        for index in 0..incident.len() {
849            let r = root(&mut parent, index);
850            let component = *component_of.entry(r).or_insert_with(|| {
851                components += 1;
852                components - 1
853            });
854            assignment[index] = component;
855        }
856        if components < 2 {
857            continue;
858        }
859        // Keep the original record for component 0; every further fan gets a
860        // duplicate vertex at the same point.
861        let point = vertex_of_id
862            .get(&vertex_id)
863            .map(|&index| solid.vertices[index].point)
864            .ok_or("split_pinched_vertices: vertex vanished")?;
865        let mut replacement_ids = vec![vertex_id];
866        for _ in 1..components {
867            let id = next_id;
868            next_id += 1;
869            solid.vertices.push(VertexRecord { id, point });
870            replacement_ids.push(id);
871        }
872        for (offset, edge_id) in incident.iter().enumerate() {
873            let replacement = replacement_ids[assignment[offset]];
874            if replacement == vertex_id {
875                continue;
876            }
877            if let Some(edge) = solid.edges.iter_mut().find(|edge| edge.id == *edge_id) {
878                if edge.start_vertex_id == vertex_id {
879                    edge.start_vertex_id = replacement;
880                }
881                if edge.end_vertex_id == vertex_id {
882                    edge.end_vertex_id = replacement;
883                }
884            }
885        }
886        split_count += components - 1;
887    }
888    Ok(split_count)
889}
890
891// BREP private tests: a9a5574c31d13d8c