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        if parent[index] != index {
302            parent[index] = root(parent, parent[index]);
303        }
304        parent[index]
305    }
306    for (shell_index, shell) in solid.shells.iter().enumerate() {
307        for coedge in shell
308            .faces
309            .iter()
310            .flat_map(|face| &face.loops)
311            .flat_map(|loop_record| &loop_record.coedges)
312        {
313            if let Some(&other) = owner_of_edge.get(&coedge.edge_id) {
314                let first = root(&mut parent, other);
315                let second = root(&mut parent, shell_index);
316                if first != second {
317                    parent[second] = first;
318                }
319            } else {
320                owner_of_edge.insert(coedge.edge_id, shell_index);
321            }
322        }
323    }
324    let before = solid.shells.len();
325    let original = std::mem::take(&mut solid.shells);
326    let mut merged = Vec::<ShellRecord>::new();
327    let mut group_of = HashMap::<usize, usize>::default();
328    for (index, shell) in original.into_iter().enumerate() {
329        let group = root(&mut parent, index);
330        if let Some(&target) = group_of.get(&group) {
331            merged[target].faces.extend(shell.faces);
332        } else {
333            group_of.insert(group, merged.len());
334            merged.push(shell);
335        }
336    }
337    solid.shells = merged;
338    before - solid.shells.len()
339}
340
341/// Which `second` candidates the pair search offers for a given `first`.
342///
343/// The two strategies feed the SAME loop body in the SAME ascending order, so
344/// "the prefilter is correct" reduces to one claim: the offered list is a
345/// SUPERSET of the candidates that could pass. Nothing else in the search can
346/// differ, which is what makes the parity test (`sew_prefilter_parity`) a real
347/// proof rather than a spot check — first-match-wins makes iteration order
348/// part of the RESULT, not just the cost.
349#[derive(Clone, Copy, PartialEq, Eq, Debug)]
350enum PairSearch {
351    /// Production: neighbourhood query for open chains, closed-rim shortlist
352    /// for closed rims.
353    Hashed,
354    /// The reference brute-force scan the prefilter must reproduce exactly.
355    #[cfg_attr(not(test), allow(dead_code))]
356    Exhaustive,
357}
358
359/// The integer cell of `point`, or `None` when the coordinates cannot be
360/// bucketed at all (non-finite, or so large the index overflows the i64 key).
361/// A `None` anywhere disables the prefilter for the whole pass rather than
362/// silently dropping a candidate — exact parity with the exhaustive scan is
363/// the contract, and one lost candidate would break it.
364fn grid_cell(point: Vec3, cell: f64) -> Option<[i64; 3]> {
365    let mut key = [0i64; 3];
366    for (slot, value) in key.iter_mut().zip([point.x, point.y, point.z]) {
367        if !value.is_finite() {
368            return None;
369        }
370        let scaled = (value / cell).floor();
371        // 9e15 keeps the cast well inside i64 and inside f64's exact-integer
372        // range, so `as i64` is a faithful conversion, not a saturating one.
373        if !scaled.is_finite() || scaled.abs() > 9.0e15 {
374            return None;
375        }
376        *slot = scaled as i64;
377    }
378    Some(key)
379}
380
381/// Uniform spatial hash over the ENDPOINTS of the open one-use candidates —
382/// the prefilter that turns the pair search from a full rescan per accepted
383/// pair into a neighbourhood query.
384///
385/// Cells are `2·tolerance` wide, not `tolerance`. A partner endpoint is at
386/// most `tolerance = cell/2` away, i.e. at most HALF a cell, so it lands in
387/// this cell or an immediate neighbour with a full half-cell of slack — slack
388/// that absorbs the rounding of the `point/cell` division. At `cell =
389/// tolerance` two points exactly `tolerance` apart can straddle two cell
390/// boundaries and the 3×3×3 block would still hold them, but with zero margin
391/// for the division's last bit; the factor of two costs one extra ring of
392/// mostly-empty buckets and buys certainty.
393struct EndpointGrid {
394    cell: f64,
395    buckets: HashMap<[i64; 3], Vec<usize>>,
396}
397
398impl EndpointGrid {
399    /// Bucket every open candidate under BOTH of its endpoints.
400    ///
401    /// Closed rims are deliberately absent. They are paired by mutual locus
402    /// agreement alone — `sew_solid`'s closed branch never looks at a vertex —
403    /// and a seam-ROTATED duplicate rim shares its partner's locus while
404    /// sharing no point position at all, so any point-keyed bucket (midpoint
405    /// included) would drop exactly the pair the closed lane exists to catch.
406    /// Closed rims therefore keep a linear shortlist; see `offer_seconds`.
407    ///
408    /// Candidates whose vertex records are missing are absent too: the
409    /// exhaustive scan `continue`s such a pair outright, so dropping it here
410    /// changes nothing.
411    fn build(endpoints: &[Option<[Vec3; 2]>], closed: &[bool], tolerance: f64) -> Option<Self> {
412        let cell = 2.0 * tolerance;
413        if !(cell.is_finite() && cell > 0.0) {
414            return None;
415        }
416        let mut buckets = HashMap::<[i64; 3], Vec<usize>>::default();
417        for (position, ends) in endpoints.iter().enumerate() {
418            if closed[position] {
419                continue;
420            }
421            let Some(ends) = ends else {
422                continue;
423            };
424            for point in ends {
425                buckets
426                    .entry(grid_cell(*point, cell)?)
427                    .or_default()
428                    .push(position);
429            }
430        }
431        Some(EndpointGrid { cell, buckets })
432    }
433
434    /// Every open candidate with an endpoint in the 3×3×3 block around
435    /// `point`, ascending and deduplicated. A superset of "has an endpoint
436    /// within tolerance of `point`", which is all the pair search needs.
437    /// `false` means the point itself is unbucketable and the caller must not
438    /// trust the (empty) answer.
439    fn near(&self, point: Vec3, out: &mut Vec<usize>) -> bool {
440        out.clear();
441        let Some(centre) = grid_cell(point, self.cell) else {
442            return false;
443        };
444        for dx in -1..=1 {
445            for dy in -1..=1 {
446                for dz in -1..=1 {
447                    if let Some(bucket) =
448                        self.buckets
449                            .get(&[centre[0] + dx, centre[1] + dy, centre[2] + dz])
450                    {
451                        out.extend_from_slice(bucket);
452                    }
453                }
454            }
455        }
456        out.sort_unstable();
457        out.dedup();
458        true
459    }
460}
461
462/// Fill `offered` with the `second` positions to test against
463/// `candidates[first_position]`, ASCENDING — the order the exhaustive scan
464/// used, which first-match-wins makes load-bearing.
465///
466/// Three lanes, each a provable superset of what can pass:
467/// - **No grid** (prefilter disabled, or `PairSearch::Exhaustive`): every
468///   later position, i.e. the original scan verbatim.
469/// - **Closed `first`**: the later CLOSED positions. An open `second` is
470///   rejected by the `first_closed != second_closed` test before any
471///   geometry is touched, so shortlisting them away changes nothing — but
472///   nothing finer is available, because closed-rim pairing reads the locus
473///   and not any point (see `EndpointGrid::build`). Closed rims stay
474///   quadratic in their own (small) count; that is honest, not hidden.
475/// - **Open `first`**: one neighbourhood query around `first`'s START point.
476///   An open pair is accepted only when `first`'s start matches `second`'s
477///   start (direct) or `second`'s end (reversed), so EVERY pair that can pass
478///   has an endpoint within tolerance of that single point, and one query is
479///   a complete superset. A `first` whose vertex records are missing offers
480///   nothing, matching the exhaustive scan's `continue`.
481#[allow(clippy::too_many_arguments)]
482fn offer_seconds(
483    grid: Option<&EndpointGrid>,
484    first_position: usize,
485    first_closed: bool,
486    endpoints: &[Option<[Vec3; 2]>],
487    closed_positions: &[usize],
488    candidate_count: usize,
489    neighbours: &mut Vec<usize>,
490    offered: &mut Vec<usize>,
491) {
492    offered.clear();
493    let Some(grid) = grid else {
494        offered.extend(first_position + 1..candidate_count);
495        return;
496    };
497    if first_closed {
498        offered.extend(
499            closed_positions
500                .iter()
501                .copied()
502                .filter(|&position| position > first_position),
503        );
504        return;
505    }
506    let Some([start, _]) = endpoints[first_position] else {
507        return;
508    };
509    if grid.near(start, neighbours) {
510        offered.extend(
511            neighbours
512                .iter()
513                .copied()
514                .filter(|&position| position > first_position),
515        );
516    }
517}
518
519/// Best-effort sew of a solid's open boundary edges.
520///
521/// Pairs coincident one-use edges (open chains by matched endpoints + locus
522/// agreement, closed rims by mutual locus agreement) and rebinds each pair to
523/// one shared edge, merging the shells they join. Orientation carries NO
524/// precondition: after pairing, coedge-direction coherence is propagated
525/// across the shared edges and the result is flipped outward by signed
526/// volume. Unsewable gaps stay open and are reported, never force-welded.
527pub fn sew_solid(solid: &BrepSolid, tolerance: f64) -> Result<(BrepSolid, SewReport), KernelRefusal> {
528    let (sewn, report, _examined) = sew_solid_with_search(solid, tolerance, PairSearch::Hashed)?;
529    Ok((sewn, report))
530}
531
532/// [`sew_solid`] with the pair-search strategy exposed, plus the number of
533/// (first, second) pairs the search actually examined.
534///
535/// The count is what the scaling smoke asserts on: it is a deterministic
536/// function of the input, where wall time is not, so a band set from it is a
537/// real bound rather than a machine-speed lottery.
538fn sew_solid_with_search(
539    solid: &BrepSolid,
540    tolerance: f64,
541    search: PairSearch,
542) -> Result<(BrepSolid, SewReport, usize), KernelRefusal> {
543    if !(tolerance.is_finite() && tolerance > 0.0) {
544        return Err(KernelRefusal::input(KernelStage::Collect, "tolerance", "sew_solid: tolerance must be positive"));
545    }
546    let mut result = solid.clone();
547    let open_edges_before = one_use_edge_ids(&result).len();
548    let mut edges_sewn = 0usize;
549    let mut pairs_examined = 0usize;
550    // Pairs whose rebind plan failed (unprojectable pcurve) — left open
551    // rather than retried forever.
552    //
553    // Keyed UNORDERED, because `orient_pair` may swap which of the two is kept
554    // and which is removed. Keyed by (keep, remove) the block would be looked
555    // up under (first, second), miss on a swapped pair, and the scan would
556    // re-offer it, re-swap it, and fail to plan it again on every outer
557    // iteration — nothing having been mutated in between, that is a hang, not
558    // a retry. A pair that cannot be rebound is blocked whichever way round.
559    let mut blocked = HashSet::<(u64, u64)>::default();
560    let unordered = |first: u64, second: u64| (first.min(second), first.max(second));
561    let mut offered = Vec::<usize>::new();
562    let mut neighbours = Vec::<usize>::new();
563    loop {
564        let one_use = one_use_edge_ids(&result);
565        // Candidate POSITIONS into `result.edges`, in edge order — the same
566        // sequence the exhaustive scan walked, without cloning a NURBS curve
567        // per candidate per accepted pair (that clone was the other O(k·n)
568        // term hiding behind the O(k·n²) comparison count).
569        let candidates = result
570            .edges
571            .iter()
572            .enumerate()
573            .filter(|(_, edge)| !edge.degenerate && one_use.contains(&edge.id))
574            .map(|(index, _)| index)
575            .collect::<Vec<_>>();
576        let points = result
577            .vertices
578            .iter()
579            .map(|vertex| (vertex.id, vertex.point))
580            .collect::<HashMap<_, _>>();
581        let closed = candidates
582            .iter()
583            .map(|&index| {
584                let edge = &result.edges[index];
585                edge.start_vertex_id == edge.end_vertex_id
586            })
587            .collect::<Vec<_>>();
588        // Both endpoint positions, or `None` when a vertex record is missing —
589        // exactly the tuple the exhaustive scan destructures, hoisted out of
590        // the inner loop so each candidate's two lookups happen once.
591        let endpoints = candidates
592            .iter()
593            .map(|&index| {
594                let edge = &result.edges[index];
595                match (
596                    points.get(&edge.start_vertex_id),
597                    points.get(&edge.end_vertex_id),
598                ) {
599                    (Some(&start), Some(&end)) => Some([start, end]),
600                    _ => None,
601                }
602            })
603            .collect::<Vec<_>>();
604        let closed_positions = (0..candidates.len())
605            .filter(|&position| closed[position])
606            .collect::<Vec<_>>();
607        let grid = match search {
608            PairSearch::Hashed => EndpointGrid::build(&endpoints, &closed, tolerance),
609            PairSearch::Exhaustive => None,
610        };
611
612        let mut chosen = None;
613        'pairs: for first_position in 0..candidates.len() {
614            let first = &result.edges[candidates[first_position]];
615            let first_closed = closed[first_position];
616            offer_seconds(
617                grid.as_ref(),
618                first_position,
619                first_closed,
620                &endpoints,
621                &closed_positions,
622                candidates.len(),
623                &mut neighbours,
624                &mut offered,
625            );
626            for second_position in offered.iter().copied() {
627                let second = &result.edges[candidates[second_position]];
628                pairs_examined += 1;
629                if blocked.contains(&unordered(first.id, second.id)) {
630                    continue;
631                }
632                if first_closed != closed[second_position] {
633                    continue;
634                }
635                if !first_closed {
636                    let (Some([fs, fe]), Some([ss, se])) =
637                        (endpoints[first_position], endpoints[second_position])
638                    else {
639                        continue;
640                    };
641                    let direct =
642                        fs.sub(ss).length() <= tolerance && fe.sub(se).length() <= tolerance;
643                    let reversed =
644                        fs.sub(se).length() <= tolerance && fe.sub(ss).length() <= tolerance;
645                    if !direct && !reversed {
646                        continue;
647                    }
648                }
649                if locus_deviation(first, second, 8)? > tolerance
650                    || locus_deviation(second, first, 8)? > tolerance
651                {
652                    continue;
653                }
654                let Some((keep, remove)) = orient_pair(&result, first, second, &points, tolerance)
655                else {
656                    // A seam-rotated rim pair with no safe direction: leave it
657                    // open, do not sew a torn loop shut.
658                    continue;
659                };
660                chosen = Some((keep.clone(), remove.clone()));
661                break 'pairs;
662            }
663        }
664        let Some((keep, remove)) = chosen else {
665            break;
666        };
667        match plan_rebind(&result, &keep, &remove, tolerance) {
668            Ok(patches) => {
669                apply_rebind(&mut result, &keep, &remove, patches, tolerance);
670                edges_sewn += 1;
671            }
672            Err(_) => {
673                blocked.insert(unordered(keep.id, remove.id));
674            }
675        }
676    }
677    // Drop vertices only referenced by removed duplicate edges.
678    let used_vertices = result
679        .edges
680        .iter()
681        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
682        .collect::<HashSet<_>>();
683    result
684        .vertices
685        .retain(|vertex| used_vertices.contains(&vertex.id));
686
687    let shells_merged = merge_connected_shells(&mut result);
688    let mut oriented_outward = false;
689    if edges_sewn > 0 {
690        // Components can arrive arbitrarily flipped; make the coedge
691        // directions coherent across the shared edges, then restore outward
692        // normals by signed volume.
693        orient_open_solid_faces(&mut result)?;
694        if let Ok(volume) = solid_signed_volume(&result) {
695            if volume < 0.0 {
696                flip_all_faces(&mut result)?;
697            }
698            oriented_outward = volume.abs() > tolerance * tolerance * tolerance;
699        }
700    }
701    // Re-derive genus from the Euler characteristic (V − E + F − H = 2 − 2g,
702    // H counting inner loops, matching validate()) so validation sees the
703    // merged topology, not the input's bookkeeping. Only when sewing actually
704    // changed the topology — a no-op must not touch a valid genus.
705    if edges_sewn > 0 || shells_merged > 0 {
706        let vertex_count = result.vertices.len() as i64;
707        let edge_count = result.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
708        let face_count = result
709            .shells
710            .iter()
711            .map(|shell| shell.faces.len())
712            .sum::<usize>() as i64;
713        let ring_count = result
714            .shells
715            .iter()
716            .flat_map(|shell| &shell.faces)
717            .map(|face| face.loops.len().saturating_sub(1))
718            .sum::<usize>() as i64;
719        let numerator = 2 - (vertex_count - edge_count + face_count - ring_count);
720        if numerator >= 0 && numerator % 2 == 0 {
721            result.genus = numerator / 2;
722        }
723    }
724    let open_edges_after = one_use_edge_ids(&result).len();
725    let issues = result
726        .validate()
727        .into_iter()
728        .map(|issue| issue.message)
729        .collect();
730    Ok((
731        result,
732        SewReport {
733            edges_sewn,
734            shells_merged,
735            open_edges_before,
736            open_edges_after,
737            oriented_outward,
738            issues,
739        },
740        pairs_examined,
741    ))
742}
743
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, KernelRefusal> {
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(KernelRefusal::internal(KernelStage::Sew, "pinched_vertex", "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