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#[cfg(test)]
744mod tests {
745    use super::*;
746    use crate::topology::VertexRecord;
747    use crate::{make_arc, make_box_brep, make_cylinder_brep, solid_mass_properties, Vec4};
748
749    fn max_id(solid: &BrepSolid) -> u64 {
750        let vertex_max = solid.vertices.iter().map(|vertex| vertex.id).max();
751        let edge_max = solid.edges.iter().map(|edge| edge.id).max();
752        let face_max = solid
753            .shells
754            .iter()
755            .flat_map(|shell| &shell.faces)
756            .flat_map(|face| {
757                std::iter::once(face.id).chain(face.loops.iter().flat_map(|loop_record| {
758                    std::iter::once(loop_record.id)
759                        .chain(loop_record.coedges.iter().map(|coedge| coedge.id))
760                }))
761            })
762            .max();
763        [vertex_max, edge_max, face_max, Some(solid.id)]
764            .into_iter()
765            .flatten()
766            .max()
767            .unwrap_or(0)
768    }
769
770    /// Move the given faces into a NEW shell whose edges and vertices are
771    /// fresh duplicates — the un-sewn state an importer or a face-group
772    /// detachment produces.
773    fn detach_faces(solid: &BrepSolid, face_ids: &[u64]) -> BrepSolid {
774        let mut result = solid.clone();
775        let mut next = max_id(&result) + 1;
776        let mut moved = Vec::new();
777        for shell in &mut result.shells {
778            let mut kept = Vec::new();
779            for face in shell.faces.drain(..) {
780                if face_ids.contains(&face.id) {
781                    moved.push(face);
782                } else {
783                    kept.push(face);
784                }
785            }
786            shell.faces = kept;
787        }
788        assert_eq!(moved.len(), face_ids.len(), "all faces found");
789        let mut edge_map = HashMap::<u64, u64>::default();
790        let mut vertex_map = HashMap::<u64, u64>::default();
791        for face in &mut moved {
792            for coedge in face
793                .loops
794                .iter_mut()
795                .flat_map(|loop_record| &mut loop_record.coedges)
796            {
797                if let Some(&mapped) = edge_map.get(&coedge.edge_id) {
798                    coedge.edge_id = mapped;
799                    continue;
800                }
801                let mut duplicate = result
802                    .edges
803                    .iter()
804                    .find(|edge| edge.id == coedge.edge_id)
805                    .expect("edge exists")
806                    .clone();
807                for vertex_id in [&mut duplicate.start_vertex_id, &mut duplicate.end_vertex_id] {
808                    if let Some(&mapped) = vertex_map.get(vertex_id) {
809                        *vertex_id = mapped;
810                        continue;
811                    }
812                    let point = result
813                        .vertices
814                        .iter()
815                        .find(|vertex| vertex.id == *vertex_id)
816                        .expect("vertex exists")
817                        .point;
818                    result.vertices.push(VertexRecord { id: next, point });
819                    vertex_map.insert(*vertex_id, next);
820                    *vertex_id = next;
821                    next += 1;
822                }
823                duplicate.id = next;
824                next += 1;
825                edge_map.insert(coedge.edge_id, duplicate.id);
826                coedge.edge_id = duplicate.id;
827                result.edges.push(duplicate);
828            }
829        }
830        result.shells.push(ShellRecord {
831            id: next,
832            faces: moved,
833        });
834        // Originals whose every use moved (edges interior to the moved group)
835        // are now orphaned — purge them as a real detachment would.
836        let used_edges = result
837            .shells
838            .iter()
839            .flat_map(|shell| &shell.faces)
840            .flat_map(|face| &face.loops)
841            .flat_map(|loop_record| &loop_record.coedges)
842            .map(|coedge| coedge.edge_id)
843            .collect::<HashSet<_>>();
844        result.edges.retain(|edge| used_edges.contains(&edge.id));
845        let used_vertices = result
846            .edges
847            .iter()
848            .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
849            .collect::<HashSet<_>>();
850        result
851            .vertices
852            .retain(|vertex| used_vertices.contains(&vertex.id));
853        result
854    }
855
856    /// Translate every face surface, edge curve, and vertex belonging to the
857    /// given shell — its geometry is fully duplicated by `detach_faces`, so
858    /// nothing shared moves.
859    fn translate_shell(solid: &mut BrepSolid, shell_index: usize, delta: Vec3) {
860        let mut edge_ids = HashSet::default();
861        for face in &mut solid.shells[shell_index].faces {
862            for row in &mut face.surface.control_points {
863                for control in row.iter_mut() {
864                    let point = control.point().unwrap().add(delta);
865                    *control = Vec4::from_point(point, control.w);
866                }
867            }
868            for coedge in face
869                .loops
870                .iter_mut()
871                .flat_map(|loop_record| &mut loop_record.coedges)
872            {
873                edge_ids.insert(coedge.edge_id);
874            }
875        }
876        let mut vertex_ids = HashSet::default();
877        for edge in &mut solid.edges {
878            if !edge_ids.contains(&edge.id) {
879                continue;
880            }
881            for control in &mut edge.curve.control_points {
882                let point = control.point().unwrap().add(delta);
883                *control = Vec4::from_point(point, control.w);
884            }
885            vertex_ids.insert(edge.start_vertex_id);
886            vertex_ids.insert(edge.end_vertex_id);
887        }
888        for vertex in &mut solid.vertices {
889            if vertex_ids.contains(&vertex.id) {
890                vertex.point = vertex.point.add(delta);
891            }
892        }
893    }
894
895    fn one_use_count(solid: &BrepSolid) -> usize {
896        one_use_edge_ids(solid).len()
897    }
898
899    /// Faces whose sampled centroid x exceeds the split coordinate.
900    fn faces_beyond_x(solid: &BrepSolid, x: f64) -> Vec<u64> {
901        solid
902            .shells
903            .iter()
904            .flat_map(|shell| &shell.faces)
905            .filter(|face| {
906                face.surface
907                    .evaluate(0.5, 0.5)
908                    .map(|point| point.x > x)
909                    .unwrap_or(false)
910            })
911            .map(|face| face.id)
912            .collect()
913    }
914
915    #[test]
916    fn sews_detached_box_faces_into_watertight_solid() {
917        let solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
918        let moved = faces_beyond_x(&solid, 3.9); // the +x face
919        assert_eq!(moved.len(), 1);
920        let detached = detach_faces(&solid, &moved);
921        assert_eq!(detached.shells.len(), 2);
922        assert_eq!(one_use_count(&detached), 8, "4 rim edges duplicated");
923
924        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
925        assert_eq!(report.edges_sewn, 4, "{report:?}");
926        assert_eq!(report.shells_merged, 1);
927        assert_eq!(report.open_edges_after, 0);
928        assert!(report.issues.is_empty(), "{:?}", report.issues);
929        assert_eq!(sewn.shells.len(), 1);
930        assert_eq!(one_use_count(&sewn), 0);
931        let volume = solid_mass_properties(&sewn).unwrap().volume;
932        assert!((volume - 24.0).abs() < 1e-9, "{volume}");
933    }
934
935    #[test]
936    fn sews_a_flipped_component_and_orients_outward() {
937        let solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
938        let moved = faces_beyond_x(&solid, 3.9);
939        let mut detached = detach_faces(&solid, &moved);
940        // The detached component arrives with the opposite orientation
941        // convention — sewing must not require any pre-agreement.
942        crate::offset_shell::flip_shell_faces(&mut detached.shells[1]).unwrap();
943
944        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
945        assert_eq!(report.edges_sewn, 4, "{report:?}");
946        assert!(report.issues.is_empty(), "{:?}", report.issues);
947        assert_eq!(one_use_count(&sewn), 0);
948        let volume = solid_signed_volume(&sewn).unwrap();
949        assert!((volume - 24.0).abs() < 1e-9, "outward positive: {volume}");
950    }
951
952    #[test]
953    fn sews_a_detached_cap_along_its_closed_rim() {
954        let axis = Vec3::new(0.0, 0.0, 1.0);
955        let solid = make_cylinder_brep(Vec3::default(), axis, 1.5, 3.0).unwrap();
956        // Top cap: the face whose centroid sits highest along the axis.
957        let top = solid.shells[0]
958            .faces
959            .iter()
960            .max_by(|first, second| {
961                let height = |face: &FaceRecord| {
962                    face.surface
963                        .evaluate(0.5, 0.5)
964                        .map(|point| point.z)
965                        .unwrap_or(f64::NEG_INFINITY)
966                };
967                height(first).total_cmp(&height(second))
968            })
969            .unwrap()
970            .id;
971        let detached = detach_faces(&solid, &[top]);
972        assert_eq!(one_use_count(&detached), 2, "one closed rim duplicated");
973
974        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
975        assert_eq!(report.edges_sewn, 1, "{report:?}");
976        assert_eq!(report.open_edges_after, 0);
977        assert!(report.issues.is_empty(), "{:?}", report.issues);
978        assert_eq!(sewn.shells.len(), 1);
979        let volume = solid_mass_properties(&sewn).unwrap().volume;
980        let expected = std::f64::consts::PI * 1.5 * 1.5 * 3.0;
981        assert!((volume - expected).abs() < 1e-6, "{volume} vs {expected}");
982    }
983
984    #[test]
985    fn sews_a_multi_face_group_with_shared_corner_vertices() {
986        // Three faces meeting at a corner: their duplicated rims share
987        // duplicated vertices, so several sews must chain consistently.
988        let solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
989        let moved = solid.shells[0]
990            .faces
991            .iter()
992            .filter(|face| {
993                face.surface
994                    .evaluate(0.5, 0.5)
995                    .map(|point| point.x > 3.9 || point.y > 2.9 || point.z > 1.9)
996                    .unwrap_or(false)
997            })
998            .map(|face| face.id)
999            .collect::<Vec<_>>();
1000        assert_eq!(moved.len(), 3);
1001        let detached = detach_faces(&solid, &moved);
1002
1003        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
1004        assert_eq!(report.open_edges_after, 0, "{report:?}");
1005        assert!(report.issues.is_empty(), "{:?}", report.issues);
1006        assert_eq!(sewn.shells.len(), 1);
1007        let volume = solid_mass_properties(&sewn).unwrap().volume;
1008        assert!((volume - 24.0).abs() < 1e-9, "{volume}");
1009    }
1010
1011    #[test]
1012    fn sews_reparametrized_duplicates_via_pcurve_refit() {
1013        // The duplicated rim edges arrive with REVERSED curves (same locus,
1014        // different parametrization): the fast keep-the-pcurve path cannot
1015        // apply, so the rebind must refit pcurves and derive `forward` from
1016        // the 3D walk alone.
1017        let solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
1018        let moved = faces_beyond_x(&solid, 3.9);
1019        let mut detached = detach_faces(&solid, &moved);
1020        let duplicated = detached.shells[1]
1021            .faces
1022            .iter()
1023            .flat_map(|face| &face.loops)
1024            .flat_map(|loop_record| &loop_record.coedges)
1025            .map(|coedge| coedge.edge_id)
1026            .collect::<HashSet<_>>();
1027        for edge in &mut detached.edges {
1028            if duplicated.contains(&edge.id) {
1029                edge.curve = edge.curve.reversed().unwrap();
1030                [edge.t0, edge.t1] = edge.curve.domain().unwrap();
1031                std::mem::swap(&mut edge.start_vertex_id, &mut edge.end_vertex_id);
1032            }
1033        }
1034
1035        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
1036        assert_eq!(report.edges_sewn, 4, "{report:?}");
1037        assert_eq!(report.open_edges_after, 0);
1038        assert!(report.issues.is_empty(), "{:?}", report.issues);
1039        let volume = solid_mass_properties(&sewn).unwrap().volume;
1040        assert!((volume - 24.0).abs() < 1e-9, "{volume}");
1041    }
1042
1043    #[test]
1044    fn sews_a_holed_face_with_ring_aware_genus() {
1045        // Block with a vertical through-hole: the top face carries an INNER
1046        // loop (the hole rim). Detaching and re-sewing it exercises closed-rim
1047        // pairing on a ringed face and the ring-aware (V−E+F−H) genus
1048        // recompute — an outer-loops-only Euler count would corrupt genus
1049        // here and trip validation.
1050        let block = make_box_brep(Vec3::default(), 4.0, 4.0, 2.0).unwrap();
1051        let drill = make_cylinder_brep(
1052            Vec3::new(2.0, 2.0, -1.0),
1053            Vec3::new(0.0, 0.0, 1.0),
1054            1.0,
1055            4.0,
1056        )
1057        .unwrap();
1058        let holed = crate::boolean_operation(
1059            &block,
1060            &drill,
1061            crate::BooleanOperation::Subtract,
1062            &crate::BooleanOptions::default(),
1063        )
1064        .unwrap();
1065        let top = holed
1066            .shells
1067            .iter()
1068            .flat_map(|shell| &shell.faces)
1069            .find(|face| {
1070                face.loops.len() > 1
1071                    && face
1072                        .surface
1073                        .evaluate(0.05, 0.05)
1074                        .map(|point| point.z > 1.9)
1075                        .unwrap_or(false)
1076            })
1077            .expect("holed top face")
1078            .id;
1079        let detached = detach_faces(&holed, &[top]);
1080
1081        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
1082        assert_eq!(report.open_edges_after, 0, "{report:?}");
1083        assert!(report.issues.is_empty(), "{:?}", report.issues);
1084        assert_eq!(sewn.shells.len(), 1);
1085        let expected = 4.0 * 4.0 * 2.0 - std::f64::consts::PI * 2.0;
1086        let volume = solid_mass_properties(&sewn).unwrap().volume;
1087        assert!((volume - expected).abs() < 1e-6, "{volume} vs {expected}");
1088    }
1089
1090    #[test]
1091    fn watertight_input_is_a_no_op() {
1092        let solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
1093        let (sewn, report) = sew_solid(&solid, 1e-6).unwrap();
1094        assert_eq!(report.edges_sewn, 0);
1095        assert_eq!(report.shells_merged, 0);
1096        assert_eq!(report.open_edges_before, 0);
1097        assert_eq!(report.open_edges_after, 0);
1098        assert!(report.issues.is_empty(), "{:?}", report.issues);
1099        let volume = solid_mass_properties(&sewn).unwrap().volume;
1100        assert!((volume - 24.0).abs() < 1e-9, "{volume}");
1101    }
1102
1103    #[test]
1104    fn reports_an_unsewable_gap_honestly() {
1105        let solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
1106        let moved = faces_beyond_x(&solid, 3.9);
1107        let mut detached = detach_faces(&solid, &moved);
1108        translate_shell(&mut detached, 1, Vec3::new(0.5, 0.0, 0.0));
1109
1110        let open_before = one_use_count(&detached);
1111        let (sewn, report) = sew_solid(&detached, 1e-4).unwrap();
1112        assert_eq!(report.edges_sewn, 0, "{report:?}");
1113        assert_eq!(report.shells_merged, 0);
1114        assert_eq!(report.open_edges_after, open_before);
1115        assert_eq!(sewn.shells.len(), 2, "nothing force-welded");
1116    }
1117
1118    /// The cap face of a z-up cylinder: the one whose surface centroid sits
1119    /// highest along the axis.
1120    fn top_cap_face_id(solid: &BrepSolid) -> u64 {
1121        solid.shells[0]
1122            .faces
1123            .iter()
1124            .max_by(|first, second| {
1125                let height = |face: &FaceRecord| {
1126                    face.surface
1127                        .evaluate(0.5, 0.5)
1128                        .map(|point| point.z)
1129                        .unwrap_or(f64::NEG_INFINITY)
1130                };
1131                height(first).total_cmp(&height(second))
1132            })
1133            .unwrap()
1134            .id
1135    }
1136
1137    /// A cylinder whose detached cap carries a SEAM-ROTATED copy of the shared
1138    /// rim: the same circle, rebuilt as a full turn starting 0.7 rad round, so
1139    /// the two rims share a locus and share NO point.
1140    ///
1141    /// This is what a periodic wall meeting an independently-authored cap
1142    /// actually looks like, and it is the state `apply_rebind`'s fence was
1143    /// written for — "Only coincident endpoints weld — a rotated closed rim
1144    /// keeps its own seam vertex" — and which nothing drove, because every
1145    /// other fixture duplicates a rim TOGETHER WITH its seam, so the seams
1146    /// coincide and the weld always fires.
1147    struct SeamRotatedCap {
1148        solid: BrepSolid,
1149        /// The cap's copy of the rim — the edge whose seam was moved.
1150        rim_id: u64,
1151        /// Where the wall's rim still starts.
1152        original_seam: Vec3,
1153        /// Where the cap's rim now starts.
1154        rotated_seam: Vec3,
1155    }
1156
1157    fn seam_rotated_cap() -> SeamRotatedCap {
1158        const SEAM_ROTATION: f64 = 0.7;
1159        let axis = Vec3::new(0.0, 0.0, 1.0);
1160        let solid = make_cylinder_brep(Vec3::default(), axis, 1.5, 3.0).unwrap();
1161        let top = top_cap_face_id(&solid);
1162        let mut detached = detach_faces(&solid, &[top]);
1163        assert_eq!(one_use_count(&detached), 2, "one closed rim duplicated");
1164
1165        // The cap's own copy of the rim: the closed one-use edge inside the
1166        // detached shell.
1167        let cap_edges = detached.shells[1]
1168            .faces
1169            .iter()
1170            .flat_map(|face| &face.loops)
1171            .flat_map(|loop_record| &loop_record.coedges)
1172            .map(|coedge| coedge.edge_id)
1173            .collect::<HashSet<_>>();
1174        let rim_id = *cap_edges
1175            .iter()
1176            .find(|id| {
1177                detached
1178                    .edges
1179                    .iter()
1180                    .find(|edge| edge.id == **id)
1181                    .is_some_and(|edge| {
1182                        edge.start_vertex_id == edge.end_vertex_id && !edge.degenerate
1183                    })
1184            })
1185            .expect("the cap carries one closed rim");
1186
1187        // Recover the circle's frame from the rim itself rather than assuming
1188        // where `make_cylinder_brep` put it: four evenly-spaced samples average
1189        // to the centre, and the quarter-turn sample gives an orthogonal axis.
1190        let (centre, x_axis, y_axis, radius) = {
1191            let rim = detached
1192                .edges
1193                .iter()
1194                .find(|edge| edge.id == rim_id)
1195                .unwrap();
1196            let sample = |fraction: f64| curve_point(rim, fraction).unwrap();
1197            let centre = sample(0.0)
1198                .add(sample(0.25))
1199                .add(sample(0.5))
1200                .add(sample(0.75))
1201                .scale(0.25);
1202            let radial = sample(0.0).sub(centre);
1203            let quarter = sample(0.25).sub(centre);
1204            (
1205                centre,
1206                radial.normalized().unwrap(),
1207                quarter.normalized().unwrap(),
1208                radial.length(),
1209            )
1210        };
1211        let rotated = make_arc(
1212            centre,
1213            x_axis,
1214            y_axis,
1215            radius,
1216            SEAM_ROTATION,
1217            SEAM_ROTATION + std::f64::consts::TAU,
1218        )
1219        .unwrap();
1220        let rotated_domain = rotated.domain().unwrap();
1221        let rotated_seam = rotated.evaluate(rotated_domain[0]).unwrap();
1222        let original_seam = {
1223            let rim = detached
1224                .edges
1225                .iter()
1226                .find(|edge| edge.id == rim_id)
1227                .unwrap();
1228            curve_point(rim, 0.0).unwrap()
1229        };
1230        assert!(
1231            rotated_seam.sub(original_seam).length() > 0.5,
1232            "the fixture must actually move the seam or it proves nothing"
1233        );
1234
1235        // Re-seat the cap's rim: new curve, new domain, and its seam vertex
1236        // moved to the new start point (that vertex is the cap's own duplicate,
1237        // referenced by nothing else).
1238        let seam_vertex_id;
1239        {
1240            let rim = detached
1241                .edges
1242                .iter_mut()
1243                .find(|edge| edge.id == rim_id)
1244                .unwrap();
1245            seam_vertex_id = rim.start_vertex_id;
1246            rim.curve = rotated.clone();
1247            [rim.t0, rim.t1] = rotated_domain;
1248        }
1249        for vertex in &mut detached.vertices {
1250            if vertex.id == seam_vertex_id {
1251                vertex.point = rotated_seam;
1252            }
1253        }
1254        // The cap's pcurve must trace the NEW parametrization, exactly the way
1255        // `plan_rebind` derives one.
1256        for face in &mut detached.shells[1].faces {
1257            for loop_record in &mut face.loops {
1258                for coedge in &mut loop_record.coedges {
1259                    if coedge.edge_id != rim_id {
1260                        continue;
1261                    }
1262                    let traversal = if coedge.forward {
1263                        rotated.clone()
1264                    } else {
1265                        rotated.reversed().unwrap()
1266                    };
1267                    coedge.pcurve = build_pcurve_on_surface(&face.surface, &traversal).unwrap();
1268                }
1269            }
1270        }
1271
1272        SeamRotatedCap {
1273            solid: detached,
1274            rim_id,
1275            original_seam,
1276            rotated_seam,
1277        }
1278    }
1279
1280    #[test]
1281    fn seam_rotated_closed_rim_sews_without_dragging_a_seam_vertex() {
1282        // Three things must hold at once: closed-rim pairing (which reads the
1283        // locus alone, never a vertex) must still find the pair; `plan_rebind`
1284        // must refit the pcurve because `same_parametrization` is false; and
1285        // `apply_rebind` must leave the seam vertices unwelded rather than
1286        // dragging the wall's seam 0.7 rad round the circle onto the cap's.
1287        let SeamRotatedCap {
1288            solid: detached,
1289            rim_id,
1290            original_seam,
1291            rotated_seam,
1292        } = seam_rotated_cap();
1293
1294        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
1295        assert_eq!(report.edges_sewn, 1, "{report:?}");
1296        assert_eq!(report.open_edges_after, 0, "{report:?}");
1297        assert!(report.issues.is_empty(), "{:?}", report.issues);
1298        assert_eq!(sewn.shells.len(), 1);
1299        let volume = solid_mass_properties(&sewn).unwrap().volume;
1300        let expected = std::f64::consts::PI * 1.5 * 1.5 * 3.0;
1301        assert!((volume - expected).abs() < 1e-6, "{volume} vs {expected}");
1302
1303        // The point of the fence: the surviving seam vertex is the WALL's,
1304        // still at angle 0, and nothing was dragged 0.7 rad round to meet the
1305        // cap's. The cap's rotated seam vertex is simply unreferenced now and
1306        // dropped with the removed duplicate edge.
1307        assert!(
1308            sewn.vertices
1309                .iter()
1310                .any(|vertex| vertex.point.sub(original_seam).length() < 1e-12),
1311            "the wall's seam vertex must be untouched"
1312        );
1313        assert!(
1314            !sewn.vertices
1315                .iter()
1316                .any(|vertex| vertex.point.sub(rotated_seam).length() < 1e-9),
1317            "the rotated seam vertex must be dropped, not welded onto"
1318        );
1319
1320        // And the SAME result with the cap's rim first in edge order, which is
1321        // what makes `orient_pair` load-bearing rather than decorative. The
1322        // scan keeps `first` and removes `second`, so this ordering asks for
1323        // the wall's rim to be the one deleted — and the wall's seam vertex is
1324        // shared with the cylinder's vertical seam edge, so deleting it tears
1325        // the wall's loop open. Before `orient_pair` this returned
1326        // `edges_sewn: 1, open_edges_after: 0` over a solid carrying four
1327        // validation issues ("loop 100 of face 105 is open between coedges 102
1328        // and 103", a 1.028693-vs-0.079287 pcurve deviation, and an Euler
1329        // characteristic of 3 where 2 was expected). Now the pair is oriented
1330        // the only way that closes both loops, and the answer is
1331        // order-independent.
1332        let mut cap_rim_first = detached.clone();
1333        let at = cap_rim_first
1334            .edges
1335            .iter()
1336            .position(|edge| edge.id == rim_id)
1337            .unwrap();
1338        let rim = cap_rim_first.edges.remove(at);
1339        cap_rim_first.edges.insert(0, rim);
1340        let (flipped, flipped_report) = sew_solid(&cap_rim_first, 1e-6).unwrap();
1341        assert_eq!(flipped_report.edges_sewn, 1, "{flipped_report:?}");
1342        assert_eq!(flipped_report.open_edges_after, 0, "{flipped_report:?}");
1343        assert!(
1344            flipped_report.issues.is_empty(),
1345            "edge order must not decide whether the sew is valid: {:?}",
1346            flipped_report.issues
1347        );
1348        let flipped_volume = solid_mass_properties(&flipped).unwrap().volume;
1349        assert!(
1350            (flipped_volume - expected).abs() < 1e-6,
1351            "{flipped_volume} vs {expected}"
1352        );
1353    }
1354
1355    #[test]
1356    fn a_seam_rotated_pair_whose_rebind_cannot_be_planned_is_blocked_once() {
1357        // `blocked` is keyed UNORDERED because `orient_pair` may swap which of
1358        // the two rims is kept. Keyed by (keep, remove) and looked up as
1359        // (first, second), a swapped pair whose rebind plan fails is never
1360        // recognised as blocked: nothing was mutated, so the next outer
1361        // iteration re-offers the same pair, re-swaps it, fails to plan it
1362        // again, and re-inserts the key it already holds — forever. That is
1363        // the one promise the `blocked` set exists to keep ("left open rather
1364        // than retried forever") and the swap quietly broke it.
1365        //
1366        // The fixture is the seam-rotated cap with the cap's rim FIRST in edge
1367        // order, so `orient_pair` swaps (the cap's seam vertex bounds nothing
1368        // else; the wall's anchors the vertical seam edge), plus an
1369        // unevaluable pcurve on the coedge `plan_rebind` must walk. An extra
1370        // knot breaks the knots-imply-control-points invariant; the solid is
1371        // then round-tripped through JSON because `NurbsCurve::validated` is
1372        // `#[serde(skip)]`, so a deserialized curve re-runs the full checks on
1373        // first use — which is also the realistic route in: a saved part file
1374        // carrying a malformed curve-on-surface.
1375        //
1376        // Run on a worker thread with a clock, because the regression is a
1377        // HANG, and a hang is not a test failure unless someone is timing it.
1378        let SeamRotatedCap {
1379            solid: mut fixture,
1380            rim_id,
1381            ..
1382        } = seam_rotated_cap();
1383        let at = fixture
1384            .edges
1385            .iter()
1386            .position(|edge| edge.id == rim_id)
1387            .unwrap();
1388        let rim = fixture.edges.remove(at);
1389        fixture.edges.insert(0, rim);
1390        // `orient_pair` keeps the wall's rim and removes the cap's, so
1391        // `plan_rebind` walks the CAP's coedge. Break that one.
1392        let mut broken = 0usize;
1393        for face in &mut fixture.shells[1].faces {
1394            for loop_record in &mut face.loops {
1395                for coedge in &mut loop_record.coedges {
1396                    if coedge.edge_id == rim_id {
1397                        coedge.pcurve.knots.push(2.0);
1398                        broken += 1;
1399                    }
1400                }
1401            }
1402        }
1403        assert_eq!(broken, 1, "exactly one cap coedge rides the rotated rim");
1404        let fixture: BrepSolid =
1405            serde_json::from_str(&serde_json::to_string(&fixture).unwrap()).unwrap();
1406        assert!(
1407            fixture.shells[1].faces[0].loops[0].coedges[0]
1408                .pcurve
1409                .domain()
1410                .is_err(),
1411            "the fixture must actually make plan_rebind fail"
1412        );
1413
1414        let (sender, receiver) = std::sync::mpsc::channel();
1415        std::thread::spawn(move || {
1416            let _ = sender.send(sew_solid(&fixture, 1e-6).map(|(_solid, report)| report));
1417        });
1418        let report = receiver
1419            .recv_timeout(std::time::Duration::from_secs(30))
1420            .expect("sew_solid must terminate on a pair it cannot plan, not spin on it")
1421            .expect("sew_solid itself does not error here");
1422        assert_eq!(report.edges_sewn, 0, "{report:?}");
1423        assert_eq!(
1424            report.open_edges_after, report.open_edges_before,
1425            "an unplannable pair stays open and is counted: {report:?}"
1426        );
1427    }
1428
1429    // -----------------------------------------------------------------
1430    // Spatial-hash prefilter: parity with the brute-force scan, and scale
1431    // -----------------------------------------------------------------
1432
1433    /// Every id in `solid` shifted by `offset`, so two copies can live in one
1434    /// `BrepSolid` without colliding. Geometry is untouched.
1435    fn shift_ids(solid: &BrepSolid, offset: u64) -> BrepSolid {
1436        let mut copy = solid.clone();
1437        copy.id += offset;
1438        for vertex in &mut copy.vertices {
1439            vertex.id += offset;
1440        }
1441        for edge in &mut copy.edges {
1442            edge.id += offset;
1443            edge.start_vertex_id += offset;
1444            edge.end_vertex_id += offset;
1445        }
1446        for shell in &mut copy.shells {
1447            shell.id += offset;
1448            for face in &mut shell.faces {
1449                face.id += offset;
1450                for loop_record in &mut face.loops {
1451                    loop_record.id += offset;
1452                    for coedge in &mut loop_record.coedges {
1453                        coedge.id += offset;
1454                        coedge.edge_id += offset;
1455                    }
1456                }
1457            }
1458        }
1459        copy
1460    }
1461
1462    /// A second, geometrically identical copy of `shell_index`'s faces with
1463    /// fresh ids — THREE coincident rims where there were two. With three
1464    /// mutually-sewable copies the pair search must pick a winner, so
1465    /// first-match-wins stops being an implementation detail and becomes an
1466    /// observable the parity comparison can catch.
1467    fn duplicate_shell(solid: &BrepSolid, shell_index: usize, offset: u64) -> BrepSolid {
1468        let mut result = solid.clone();
1469        let mut faces = result.shells[shell_index].faces.clone();
1470        let edge_ids = faces
1471            .iter()
1472            .flat_map(|face| &face.loops)
1473            .flat_map(|loop_record| &loop_record.coedges)
1474            .map(|coedge| coedge.edge_id)
1475            .collect::<HashSet<_>>();
1476        let mut edges = result
1477            .edges
1478            .iter()
1479            .filter(|edge| edge_ids.contains(&edge.id))
1480            .cloned()
1481            .collect::<Vec<_>>();
1482        let vertex_ids = edges
1483            .iter()
1484            .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1485            .collect::<HashSet<_>>();
1486        let mut vertices = result
1487            .vertices
1488            .iter()
1489            .filter(|vertex| vertex_ids.contains(&vertex.id))
1490            .cloned()
1491            .collect::<Vec<_>>();
1492        for vertex in &mut vertices {
1493            vertex.id += offset;
1494        }
1495        for edge in &mut edges {
1496            edge.id += offset;
1497            edge.start_vertex_id += offset;
1498            edge.end_vertex_id += offset;
1499        }
1500        for face in &mut faces {
1501            face.id += offset;
1502            for loop_record in &mut face.loops {
1503                loop_record.id += offset;
1504                for coedge in &mut loop_record.coedges {
1505                    coedge.id += offset;
1506                    coedge.edge_id += offset;
1507                }
1508            }
1509        }
1510        let shell_id = result.shells[shell_index].id + offset;
1511        result.vertices.extend(vertices);
1512        result.edges.extend(edges);
1513        result.shells.push(ShellRecord {
1514            id: shell_id,
1515            faces,
1516        });
1517        result
1518    }
1519
1520    /// Deterministic Fisher-Yates over `solid.edges`. The candidate list is
1521    /// `edges` order filtered, and first-match-wins reads that order, so a
1522    /// permutation is the sharpest adversary a prefilter has: it must offer
1523    /// the same candidates in the same ASCENDING positions no matter how the
1524    /// edge vector is laid out.
1525    fn permute_edges(solid: &BrepSolid, seed: u64) -> BrepSolid {
1526        let mut result = solid.clone();
1527        let mut state = seed | 1;
1528        for index in (1..result.edges.len()).rev() {
1529            state = state
1530                .wrapping_mul(6364136223846793005)
1531                .wrapping_add(1442695040888963407);
1532            let pick = ((state >> 33) as usize) % (index + 1);
1533            result.edges.swap(index, pick);
1534        }
1535        result
1536    }
1537
1538    /// The complete observable outcome of a sew: the serialized solid plus the
1539    /// report, or the error text. Bit-equality of this string between the two
1540    /// strategies is the parity claim.
1541    fn sew_outcome(solid: &BrepSolid, tolerance: f64, search: PairSearch) -> String {
1542        match sew_solid_with_search(solid, tolerance, search) {
1543            Ok((sewn, report, _examined)) => format!(
1544                "ok\n{}\n{report:?}",
1545                serde_json::to_string(&sewn).expect("a sewn solid serializes")
1546            ),
1547            Err(message) => format!("err {message}"),
1548        }
1549    }
1550
1551    /// Every box of `count` fully detached — each face in its own shell with
1552    /// its own duplicate edges and vertices — the boxes 100 units apart so no
1553    /// pair can sew across boxes. Six faces per box, 24 one-use edges, 12
1554    /// sewable pairs.
1555    fn detached_box_soup(count: usize) -> BrepSolid {
1556        let unit = {
1557            let mut soup = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
1558            let face_ids = soup.shells[0]
1559                .faces
1560                .iter()
1561                .map(|face| face.id)
1562                .collect::<Vec<_>>();
1563            // All but one: an emptied shell is not a state any real importer
1564            // produces, and validate() would rightly object to it.
1565            for face_id in face_ids.iter().skip(1) {
1566                soup = detach_faces(&soup, &[*face_id]);
1567            }
1568            soup
1569        };
1570        let mut soup = BrepSolid {
1571            id: 1,
1572            vertices: Vec::new(),
1573            edges: Vec::new(),
1574            shells: Vec::new(),
1575            genus: 0,
1576        };
1577        for index in 0..count {
1578            let mut copy = shift_ids(&unit, 1_000 * (index as u64 + 1));
1579            for shell_index in 0..copy.shells.len() {
1580                translate_shell(&mut copy, shell_index, Vec3::new(index as f64 * 100.0, 0.0, 0.0));
1581            }
1582            soup.vertices.extend(copy.vertices);
1583            soup.edges.extend(copy.edges);
1584            soup.shells.extend(copy.shells);
1585        }
1586        soup
1587    }
1588
1589    #[test]
1590    fn endpoint_grid_offers_a_partner_exactly_at_tolerance_across_a_cell_boundary() {
1591        // Two endpoints exactly `tolerance` apart, placed so that they fall in
1592        // DIFFERENT cells: `a` sits a hair below a cell boundary, `b` a full
1593        // tolerance beyond it. This is the case a cell-per-tolerance grid with
1594        // a naive same-cell lookup would drop.
1595        let tolerance = 1e-6;
1596        let cell = 2.0 * tolerance;
1597        let boundary = cell * 7.0;
1598        let a = Vec3::new(boundary - 1e-13, 0.0, 0.0);
1599        let b = Vec3::new(a.x + tolerance, 0.0, 0.0);
1600        assert_ne!(
1601            grid_cell(a, cell).unwrap(),
1602            grid_cell(b, cell).unwrap(),
1603            "the fixture must actually straddle a boundary or it proves nothing"
1604        );
1605        let grid = EndpointGrid::build(
1606            &[Some([a, Vec3::new(1.0, 0.0, 0.0)]), Some([b, b])],
1607            &[false, false],
1608            tolerance,
1609        )
1610        .expect("bucketable");
1611        let mut found = Vec::new();
1612        assert!(grid.near(a, &mut found));
1613        assert!(found.contains(&1), "partner across the boundary: {found:?}");
1614        assert!(grid.near(b, &mut found));
1615        assert!(found.contains(&0), "and symmetrically: {found:?}");
1616    }
1617
1618    #[test]
1619    fn endpoint_grid_refuses_to_bucket_unusable_coordinates() {
1620        // A non-finite or astronomically distant coordinate cannot be keyed;
1621        // `build` must return None so the caller falls back to the exhaustive
1622        // scan instead of silently losing that candidate.
1623        let tolerance = 1e-6;
1624        assert!(grid_cell(Vec3::new(f64::NAN, 0.0, 0.0), 2e-6).is_none());
1625        assert!(grid_cell(Vec3::new(1e30, 0.0, 0.0), 2e-6).is_none());
1626        let far = Vec3::new(1e30, 0.0, 0.0);
1627        assert!(
1628            EndpointGrid::build(&[Some([far, far])], &[false], tolerance).is_none(),
1629            "one unbucketable candidate disables the whole prefilter"
1630        );
1631    }
1632
1633    /// The eight shapes of dirt the parity comparison runs over: a single
1634    /// detached face, a three-face corner group whose duplicated rims share
1635    /// corners, a closed cylinder rim (the lane the grid deliberately does NOT
1636    /// key on a point), reversed duplicates that force a pcurve refit, three
1637    /// coincident copies where first-match-wins decides the winner, an
1638    /// unsewable gap, and a dangling vertex reference the exhaustive scan
1639    /// skips outright — plus the multi-box soup the prefilter exists for.
1640    fn parity_battery() -> [(&'static str, BrepSolid, f64); 8] {
1641        let box_solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
1642        let plus_x = faces_beyond_x(&box_solid, 3.9);
1643        let one_face = detach_faces(&box_solid, &plus_x);
1644
1645        let corner_faces = box_solid.shells[0]
1646            .faces
1647            .iter()
1648            .filter(|face| {
1649                face.surface
1650                    .evaluate(0.5, 0.5)
1651                    .map(|point| point.x > 3.9 || point.y > 2.9 || point.z > 1.9)
1652                    .unwrap_or(false)
1653            })
1654            .map(|face| face.id)
1655            .collect::<Vec<_>>();
1656        let corner_group = detach_faces(&box_solid, &corner_faces);
1657
1658        let cylinder = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 1.5, 3.0)
1659            .unwrap();
1660        let top_cap = cylinder.shells[0]
1661            .faces
1662            .iter()
1663            .max_by(|first, second| {
1664                let height = |face: &FaceRecord| {
1665                    face.surface
1666                        .evaluate(0.5, 0.5)
1667                        .map(|point| point.z)
1668                        .unwrap_or(f64::NEG_INFINITY)
1669                };
1670                height(first).total_cmp(&height(second))
1671            })
1672            .unwrap()
1673            .id;
1674        let closed_rim = detach_faces(&cylinder, &[top_cap]);
1675
1676        let reversed = {
1677            let mut detached = one_face.clone();
1678            let duplicated = detached.shells[1]
1679                .faces
1680                .iter()
1681                .flat_map(|face| &face.loops)
1682                .flat_map(|loop_record| &loop_record.coedges)
1683                .map(|coedge| coedge.edge_id)
1684                .collect::<HashSet<_>>();
1685            for edge in &mut detached.edges {
1686                if duplicated.contains(&edge.id) {
1687                    edge.curve = edge.curve.reversed().unwrap();
1688                    [edge.t0, edge.t1] = edge.curve.domain().unwrap();
1689                    std::mem::swap(&mut edge.start_vertex_id, &mut edge.end_vertex_id);
1690                }
1691            }
1692            detached
1693        };
1694
1695        let three_copies = duplicate_shell(&one_face, 1, 500_000);
1696
1697        let gapped = {
1698            let mut detached = one_face.clone();
1699            translate_shell(&mut detached, 1, Vec3::new(0.5, 0.0, 0.0));
1700            detached
1701        };
1702
1703        let dangling = {
1704            let mut detached = one_face.clone();
1705            let victim = detached.edges[0].id;
1706            for edge in &mut detached.edges {
1707                if edge.id == victim {
1708                    // A vertex id no record answers to: the exhaustive scan
1709                    // destructures `points.get(..)` and `continue`s, so the
1710                    // prefilter is free to omit it — but only if it does so on
1711                    // exactly the same pairs.
1712                    edge.start_vertex_id = 9_999_999;
1713                }
1714            }
1715            detached
1716        };
1717
1718        // The soup is where the prefilter earns its keep, so it belongs in the
1719        // parity battery too, not only in the scaling smoke.
1720        let soup = detached_box_soup(3);
1721
1722        [
1723            ("one detached face", one_face, 1e-6),
1724            ("three-face corner group", corner_group, 1e-6),
1725            ("closed cylinder rim", closed_rim, 1e-6),
1726            ("reversed duplicates", reversed, 1e-6),
1727            ("three coincident rims", three_copies, 1e-6),
1728            ("unsewable gap", gapped, 1e-4),
1729            ("dangling vertex reference", dangling, 1e-6),
1730            ("three-box soup", soup, 1e-6),
1731        ]
1732    }
1733
1734    /// The hashed prefilter must reproduce the exhaustive scan EXACTLY, over
1735    /// every shape of dirt and every edge ordering.
1736    ///
1737    /// `Exhaustive` is the original pair loop, refactored only to walk
1738    /// candidate POSITIONS instead of cloned records. That refactor was itself
1739    /// checked against the pre-prefilter module verbatim from git (a throwaway
1740    /// `#[path]` copy of it compiled alongside): all 8 cases x 4 permutations
1741    /// produced byte-identical serialized solids and reports, so AT THAT
1742    /// COMMIT "hashed == exhaustive" was also "hashed == what shipped". The
1743    /// shared loop body has since changed on purpose (`orient_pair`), so what
1744    /// this test guards from here on is the prefilter alone — which is all it
1745    /// ever needed to guard.
1746    #[test]
1747    fn sew_prefilter_parity() {
1748        for (name, solid, tolerance) in parity_battery() {
1749            for seed in [0u64, 1, 12345, 0xdead_beef] {
1750                let permuted = permute_edges(&solid, seed);
1751                let hashed = sew_outcome(&permuted, tolerance, PairSearch::Hashed);
1752                let exhaustive = sew_outcome(&permuted, tolerance, PairSearch::Exhaustive);
1753                assert_eq!(
1754                    hashed, exhaustive,
1755                    "{name} (edge order seed {seed}): the prefilter changed the result"
1756                );
1757            }
1758        }
1759    }
1760
1761    /// The soup a dirty import actually hands `sew_solid`: half the boxes sew
1762    /// cleanly, half have every face nudged off its neighbours by 1e-3 — gaps
1763    /// three orders of magnitude above the sew tolerance, so those rims can
1764    /// NEVER pair. They sit at the front of the edge order and are therefore
1765    /// rescanned from position 0 on every accepted pair. That prefix of
1766    /// permanently-open candidates is where the O(k·n²) scan actually burns,
1767    /// and it is the shape of a real torn shell or sheet body — not a
1768    /// synthetic worst case. The fully-sewable soup is the EASY case for the
1769    /// old scan (candidate 0 finds its partner on the first inner pass), which
1770    /// is exactly why it must not be the only thing measured.
1771    fn half_unsewable_box_soup(count: usize) -> BrepSolid {
1772        let mut soup = detached_box_soup(count);
1773        let nudged = soup.shells.len() / 2;
1774        for shell_index in 0..nudged {
1775            let step = (shell_index + 1) as f64 * 1e-3;
1776            translate_shell(&mut soup, shell_index, Vec3::new(step, -step, step));
1777        }
1778        soup
1779    }
1780
1781    #[test]
1782    fn sew_prefilter_scales_sub_quadratically() {
1783        // `pairs_examined` is a deterministic function of the input, so a band
1784        // set from it is a real bound rather than a machine-speed lottery.
1785        //
1786        // Measured on this fixture (2026-09-03, cargo test --lib):
1787        //   fully sewable, 21 boxes / 126 faces /  504 one-use edges:
1788        //       hashed 546      exhaustive 1995     (3.7x)
1789        //   fully sewable, 84 boxes / 504 faces / 2016 one-use edges:
1790        //       hashed 2184     exhaustive 7980     (3.7x)
1791        //   half unsewable, 12 boxes /  72 faces /  288 one-use edges:
1792        //       hashed 5412     exhaustive 1509042  (278.8x)
1793        // The plan's 500-face soup is the 84-box row; the half-unsewable row
1794        // is where the prefilter earns its keep, because a candidate with no
1795        // partner anywhere is what makes the inner scan run to the end.
1796        //
1797        // Wall clock on the half-unsewable soup (same run, debug build) shows
1798        // the quadratic term leaving:
1799        //   12 boxes /  288 edges:  326 ms hashed vs   500 ms exhaustive (1.5x)
1800        //   24 boxes /  576 edges: 1.09 s  hashed vs  3.20 s  exhaustive (2.9x)
1801        //   36 boxes /  864 edges: 2.30 s  hashed vs 10.21 s  exhaustive (4.4x)
1802        // The ratio GROWS with n, which is the point; at the smallest size the
1803        // per-iteration candidate/vertex-map rebuild still dominates, so the
1804        // 278x cut in pair examinations only buys 1.5x there. Making that
1805        // rebuild incremental is a separate follow-up, deliberately not taken
1806        // here: it cannot be done without touching what the loop observes.
1807        let small = detached_box_soup(21);
1808        let large = detached_box_soup(84);
1809        let (_, small_report, small_examined) =
1810            sew_solid_with_search(&small, 1e-6, PairSearch::Hashed).unwrap();
1811        let (_, large_report, large_examined) =
1812            sew_solid_with_search(&large, 1e-6, PairSearch::Hashed).unwrap();
1813        assert_eq!(small_report.open_edges_after, 0, "{small_report:?}");
1814        assert_eq!(large_report.open_edges_after, 0, "{large_report:?}");
1815        // 4x the boxes is 4x the one-use edges AND 4x the accepted pairs, so a
1816        // per-iteration O(n) scan would grow 16x and the old O(n²) one 64x.
1817        // Measured growth is exactly 4.00x — the neighbourhood query is O(1)
1818        // per candidate, so the total tracks the number of accepted pairs
1819        // alone. Band 4.5x: the measured value plus room for a hash-order
1820        // reshuffle, well under the 16x that would signal a lost prefilter.
1821        assert!(
1822            (large_examined as f64) <= 4.5 * (small_examined as f64),
1823            "hashed pair examinations grew super-linearly with the soup: \
1824             {small_examined} -> {large_examined}"
1825        );
1826
1827        // The case the prefilter exists for. Both strategies must still agree
1828        // (parity is the contract), and the hashed one must examine orders of
1829        // magnitude fewer pairs.
1830        let torn = half_unsewable_box_soup(12);
1831        let (torn_hashed, torn_report, torn_examined) =
1832            sew_solid_with_search(&torn, 1e-6, PairSearch::Hashed).unwrap();
1833        let (torn_reference, reference_report, reference_examined) =
1834            sew_solid_with_search(&torn, 1e-6, PairSearch::Exhaustive).unwrap();
1835        assert_eq!(
1836            serde_json::to_string(&torn_hashed).unwrap(),
1837            serde_json::to_string(&torn_reference).unwrap(),
1838            "the torn soup must sew identically under both strategies"
1839        );
1840        assert_eq!(format!("{torn_report:?}"), format!("{reference_report:?}"));
1841        assert!(
1842            torn_report.open_edges_after > 0,
1843            "the nudged half must stay honestly open: {torn_report:?}"
1844        );
1845        // Measured 1_509_042 -> 5_412, a 278.8x reduction. Band 50x: far
1846        // below the measured win, so it fails loudly if the prefilter degrades
1847        // towards a full scan, and does not re-break on a tie-order change
1848        // that shifts either count by a few percent.
1849        assert!(
1850            (reference_examined as f64) >= 50.0 * (torn_examined as f64),
1851            "prefilter did not cut the torn-soup scan: \
1852             exhaustive {reference_examined} vs hashed {torn_examined}"
1853        );
1854    }
1855}
1856
1857/// Split PINCHED vertices — points where two (or more) umbrella fans of
1858/// faces meet at a single vertex record. Local manifold checks (edge use
1859/// counts, loop closure, orientation) cannot see a pinch; it surfaces only
1860/// as an odd Euler characteristic. The link of a manifold boundary vertex is
1861/// a single edge-connected fan: union incident edges through every loop
1862/// CORNER at the vertex (consecutive coedges meeting there inside one face);
1863/// more than one component means distinct fans sharing the record — give
1864/// each extra fan its own vertex at the same point and reassign that fan's
1865/// edge endpoints. Geometry is untouched; only identity is repaired.
1866pub fn split_pinched_vertices(solid: &mut BrepSolid) -> Result<usize, String> {
1867    let mut split_count = 0usize;
1868    let vertex_ids: Vec<u64> = solid.vertices.iter().map(|vertex| vertex.id).collect();
1869    let mut next_id = solid
1870        .vertices
1871        .iter()
1872        .map(|vertex| vertex.id)
1873        .chain(solid.edges.iter().map(|edge| edge.id))
1874        .max()
1875        .unwrap_or(0)
1876        + 1;
1877    // id -> index maps built once. This routine only mutates edge endpoints
1878    // in place and appends vertices, so indices never shift: every lookup
1879    // returns the identical live record the previous `.iter().find(id==)`
1880    // scans returned. Kills the O(V*coedges*E) nested edge find below.
1881    let edge_of_id: HashMap<u64, usize> = solid
1882        .edges
1883        .iter()
1884        .enumerate()
1885        .map(|(index, edge)| (edge.id, index))
1886        .collect();
1887    let vertex_of_id: HashMap<u64, usize> = solid
1888        .vertices
1889        .iter()
1890        .enumerate()
1891        .map(|(index, vertex)| (vertex.id, index))
1892        .collect();
1893    for vertex_id in vertex_ids {
1894        // Incident edges (either endpoint; closed and degenerate included so
1895        // pole/seam structures stay connected through their corners).
1896        let incident: Vec<u64> = solid
1897            .edges
1898            .iter()
1899            .filter(|edge| edge.start_vertex_id == vertex_id || edge.end_vertex_id == vertex_id)
1900            .map(|edge| edge.id)
1901            .collect();
1902        if incident.len() < 4 {
1903            // A pinch needs at least two fans of >= 2 edges each.
1904            continue;
1905        }
1906        let index_of: HashMap<u64, usize> = incident
1907            .iter()
1908            .enumerate()
1909            .map(|(index, id)| (*id, index))
1910            .collect();
1911        let mut parent: Vec<usize> = (0..incident.len()).collect();
1912        fn root(parent: &mut [usize], index: usize) -> usize {
1913            if parent[index] != index {
1914                parent[index] = root(parent, parent[index]);
1915            }
1916            parent[index]
1917        }
1918        let edge_end = |edge_id: u64, forward: bool| -> Option<u64> {
1919            edge_of_id.get(&edge_id).map(|&index| {
1920                let edge = &solid.edges[index];
1921                if forward {
1922                    edge.end_vertex_id
1923                } else {
1924                    edge.start_vertex_id
1925                }
1926            })
1927        };
1928        for shell in &solid.shells {
1929            for face in &shell.faces {
1930                for loop_record in &face.loops {
1931                    let count = loop_record.coedges.len();
1932                    for index in 0..count {
1933                        let current = &loop_record.coedges[index];
1934                        let next = &loop_record.coedges[(index + 1) % count];
1935                        // The corner between current and next sits at
1936                        // current's traversal END vertex.
1937                        let Some(junction) = edge_end(current.edge_id, current.forward) else {
1938                            continue;
1939                        };
1940                        if junction != vertex_id {
1941                            continue;
1942                        }
1943                        let (Some(&a), Some(&b)) =
1944                            (index_of.get(&current.edge_id), index_of.get(&next.edge_id))
1945                        else {
1946                            continue;
1947                        };
1948                        let ra = root(&mut parent, a);
1949                        let rb = root(&mut parent, b);
1950                        if ra != rb {
1951                            parent[rb] = ra;
1952                        }
1953                    }
1954                }
1955            }
1956        }
1957        let mut component_of: HashMap<usize, usize> = HashMap::default();
1958        let mut components = 0usize;
1959        let mut assignment: Vec<usize> = vec![0; incident.len()];
1960        for index in 0..incident.len() {
1961            let r = root(&mut parent, index);
1962            let component = *component_of.entry(r).or_insert_with(|| {
1963                components += 1;
1964                components - 1
1965            });
1966            assignment[index] = component;
1967        }
1968        if components < 2 {
1969            continue;
1970        }
1971        // Keep the original record for component 0; every further fan gets a
1972        // duplicate vertex at the same point.
1973        let point = vertex_of_id
1974            .get(&vertex_id)
1975            .map(|&index| solid.vertices[index].point)
1976            .ok_or("split_pinched_vertices: vertex vanished")?;
1977        let mut replacement_ids = vec![vertex_id];
1978        for _ in 1..components {
1979            let id = next_id;
1980            next_id += 1;
1981            solid.vertices.push(VertexRecord { id, point });
1982            replacement_ids.push(id);
1983        }
1984        for (offset, edge_id) in incident.iter().enumerate() {
1985            let replacement = replacement_ids[assignment[offset]];
1986            if replacement == vertex_id {
1987                continue;
1988            }
1989            if let Some(edge) = solid.edges.iter_mut().find(|edge| edge.id == *edge_id) {
1990                if edge.start_vertex_id == vertex_id {
1991                    edge.start_vertex_id = replacement;
1992                }
1993                if edge.end_vertex_id == vertex_id {
1994                    edge.end_vertex_id = replacement;
1995                }
1996            }
1997        }
1998        split_count += components - 1;
1999    }
2000    Ok(split_count)
2001}
2002
2003#[cfg(test)]
2004mod pinch_tests {
2005    use super::*;
2006    use crate::make_box_brep;
2007
2008    #[test]
2009    fn a_hand_welded_corner_pinch_splits_back_to_manifold() {
2010        // Two boxes meeting at exactly one corner point; welding that corner
2011        // into ONE vertex record creates the pinch (χ drops odd), and the
2012        // splitter must give each box its own vertex back.
2013        let first = make_box_brep(Vec3::default(), 2.0, 2.0, 2.0).unwrap();
2014        let second = make_box_brep(Vec3::new(2.0, 2.0, 2.0), 2.0, 2.0, 2.0).unwrap();
2015        // Merge into one two-shell solid with disjoint id spaces.
2016        let mut solid = first.clone();
2017        let offset = 1000u64;
2018        let mut moved = second.clone();
2019        for vertex in &mut moved.vertices {
2020            vertex.id += offset;
2021        }
2022        for edge in &mut moved.edges {
2023            edge.id += offset;
2024            edge.start_vertex_id += offset;
2025            edge.end_vertex_id += offset;
2026        }
2027        for shell in &mut moved.shells {
2028            shell.id += offset;
2029            for face in &mut shell.faces {
2030                face.id += offset;
2031                for loop_record in &mut face.loops {
2032                    loop_record.id += offset;
2033                    for coedge in &mut loop_record.coedges {
2034                        coedge.id += offset;
2035                        coedge.edge_id += offset;
2036                    }
2037                }
2038            }
2039        }
2040        solid.vertices.extend(moved.vertices);
2041        solid.edges.extend(moved.edges);
2042        solid.shells.extend(moved.shells);
2043
2044        // Find the two coincident corner vertices at (2,2,2) and weld them.
2045        let corner = Vec3::new(2.0, 2.0, 2.0);
2046        let ids: Vec<u64> = solid
2047            .vertices
2048            .iter()
2049            .filter(|vertex| vertex.point.sub(corner).length() < 1e-9)
2050            .map(|vertex| vertex.id)
2051            .collect();
2052        assert_eq!(ids.len(), 2, "both corner vertices present");
2053        let (keep, remove) = (ids[0], ids[1]);
2054        for edge in &mut solid.edges {
2055            if edge.start_vertex_id == remove {
2056                edge.start_vertex_id = keep;
2057            }
2058            if edge.end_vertex_id == remove {
2059                edge.end_vertex_id = keep;
2060            }
2061        }
2062        solid.vertices.retain(|vertex| vertex.id != remove);
2063
2064        // The welded solid is pinched: local checks pass but χ is odd.
2065        let split = split_pinched_vertices(&mut solid).unwrap();
2066        assert_eq!(split, 1, "exactly one fan duplicated");
2067        assert_eq!(
2068            solid
2069                .vertices
2070                .iter()
2071                .filter(|vertex| vertex.point.sub(corner).length() < 1e-9)
2072                .count(),
2073            2,
2074            "corner vertices restored"
2075        );
2076        // Each shell is a clean box again.
2077        assert!(solid.validate().is_empty(), "{:?}", solid.validate());
2078    }
2079
2080    #[test]
2081    fn manifold_solids_are_untouched() {
2082        let mut solid = make_box_brep(Vec3::default(), 2.0, 2.0, 2.0).unwrap();
2083        let before = solid.vertices.len();
2084        assert_eq!(split_pinched_vertices(&mut solid).unwrap(), 0);
2085        assert_eq!(solid.vertices.len(), before);
2086    }
2087}