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