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/// Merge shells that now share an edge into single shells (union-find).
237fn merge_connected_shells(solid: &mut BrepSolid) -> usize {
238    let mut owner_of_edge = HashMap::<u64, usize>::default();
239    let mut parent = (0..solid.shells.len()).collect::<Vec<_>>();
240    fn root(parent: &mut [usize], index: usize) -> usize {
241        if parent[index] != index {
242            parent[index] = root(parent, parent[index]);
243        }
244        parent[index]
245    }
246    for (shell_index, shell) in solid.shells.iter().enumerate() {
247        for coedge in shell
248            .faces
249            .iter()
250            .flat_map(|face| &face.loops)
251            .flat_map(|loop_record| &loop_record.coedges)
252        {
253            if let Some(&other) = owner_of_edge.get(&coedge.edge_id) {
254                let first = root(&mut parent, other);
255                let second = root(&mut parent, shell_index);
256                if first != second {
257                    parent[second] = first;
258                }
259            } else {
260                owner_of_edge.insert(coedge.edge_id, shell_index);
261            }
262        }
263    }
264    let before = solid.shells.len();
265    let original = std::mem::take(&mut solid.shells);
266    let mut merged = Vec::<ShellRecord>::new();
267    let mut group_of = HashMap::<usize, usize>::default();
268    for (index, shell) in original.into_iter().enumerate() {
269        let group = root(&mut parent, index);
270        if let Some(&target) = group_of.get(&group) {
271            merged[target].faces.extend(shell.faces);
272        } else {
273            group_of.insert(group, merged.len());
274            merged.push(shell);
275        }
276    }
277    solid.shells = merged;
278    before - solid.shells.len()
279}
280
281/// Best-effort sew of a solid's open boundary edges.
282///
283/// Pairs coincident one-use edges (open chains by matched endpoints + locus
284/// agreement, closed rims by mutual locus agreement) and rebinds each pair to
285/// one shared edge, merging the shells they join. Orientation carries NO
286/// precondition: after pairing, coedge-direction coherence is propagated
287/// across the shared edges and the result is flipped outward by signed
288/// volume. Unsewable gaps stay open and are reported, never force-welded.
289pub fn sew_solid(solid: &BrepSolid, tolerance: f64) -> Result<(BrepSolid, SewReport), String> {
290    if !(tolerance.is_finite() && tolerance > 0.0) {
291        return Err("sew_solid: tolerance must be positive".into());
292    }
293    let mut result = solid.clone();
294    let open_edges_before = one_use_edge_ids(&result).len();
295    let mut edges_sewn = 0usize;
296    // Pairs whose rebind plan failed (unprojectable pcurve) — left open
297    // rather than retried forever.
298    let mut blocked = HashSet::<(u64, u64)>::default();
299    loop {
300        let one_use = one_use_edge_ids(&result);
301        let candidates = result
302            .edges
303            .iter()
304            .filter(|edge| !edge.degenerate && one_use.contains(&edge.id))
305            .cloned()
306            .collect::<Vec<_>>();
307        let points = result
308            .vertices
309            .iter()
310            .map(|vertex| (vertex.id, vertex.point))
311            .collect::<HashMap<_, _>>();
312        let mut chosen = None;
313        'pairs: for (index, first) in candidates.iter().enumerate() {
314            for second in candidates.iter().skip(index + 1) {
315                if blocked.contains(&(first.id, second.id)) {
316                    continue;
317                }
318                let first_closed = first.start_vertex_id == first.end_vertex_id;
319                let second_closed = second.start_vertex_id == second.end_vertex_id;
320                if first_closed != second_closed {
321                    continue;
322                }
323                if !first_closed {
324                    let (Some(&fs), Some(&fe), Some(&ss), Some(&se)) = (
325                        points.get(&first.start_vertex_id),
326                        points.get(&first.end_vertex_id),
327                        points.get(&second.start_vertex_id),
328                        points.get(&second.end_vertex_id),
329                    ) else {
330                        continue;
331                    };
332                    let direct =
333                        fs.sub(ss).length() <= tolerance && fe.sub(se).length() <= tolerance;
334                    let reversed =
335                        fs.sub(se).length() <= tolerance && fe.sub(ss).length() <= tolerance;
336                    if !direct && !reversed {
337                        continue;
338                    }
339                }
340                if locus_deviation(first, second, 8)? > tolerance
341                    || locus_deviation(second, first, 8)? > tolerance
342                {
343                    continue;
344                }
345                chosen = Some((first.clone(), second.clone()));
346                break 'pairs;
347            }
348        }
349        let Some((keep, remove)) = chosen else {
350            break;
351        };
352        match plan_rebind(&result, &keep, &remove, tolerance) {
353            Ok(patches) => {
354                apply_rebind(&mut result, &keep, &remove, patches, tolerance);
355                edges_sewn += 1;
356            }
357            Err(_) => {
358                blocked.insert((keep.id, remove.id));
359            }
360        }
361    }
362    // Drop vertices only referenced by removed duplicate edges.
363    let used_vertices = result
364        .edges
365        .iter()
366        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
367        .collect::<HashSet<_>>();
368    result
369        .vertices
370        .retain(|vertex| used_vertices.contains(&vertex.id));
371
372    let shells_merged = merge_connected_shells(&mut result);
373    let mut oriented_outward = false;
374    if edges_sewn > 0 {
375        // Components can arrive arbitrarily flipped; make the coedge
376        // directions coherent across the shared edges, then restore outward
377        // normals by signed volume.
378        orient_open_solid_faces(&mut result)?;
379        if let Ok(volume) = solid_signed_volume(&result) {
380            if volume < 0.0 {
381                flip_all_faces(&mut result)?;
382            }
383            oriented_outward = volume.abs() > tolerance * tolerance * tolerance;
384        }
385    }
386    // Re-derive genus from the Euler characteristic (V − E + F − H = 2 − 2g,
387    // H counting inner loops, matching validate()) so validation sees the
388    // merged topology, not the input's bookkeeping. Only when sewing actually
389    // changed the topology — a no-op must not touch a valid genus.
390    if edges_sewn > 0 || shells_merged > 0 {
391        let vertex_count = result.vertices.len() as i64;
392        let edge_count = result.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
393        let face_count = result
394            .shells
395            .iter()
396            .map(|shell| shell.faces.len())
397            .sum::<usize>() as i64;
398        let ring_count = result
399            .shells
400            .iter()
401            .flat_map(|shell| &shell.faces)
402            .map(|face| face.loops.len().saturating_sub(1))
403            .sum::<usize>() as i64;
404        let numerator = 2 - (vertex_count - edge_count + face_count - ring_count);
405        if numerator >= 0 && numerator % 2 == 0 {
406            result.genus = numerator / 2;
407        }
408    }
409    let open_edges_after = one_use_edge_ids(&result).len();
410    let issues = result
411        .validate()
412        .into_iter()
413        .map(|issue| issue.message)
414        .collect();
415    Ok((
416        result,
417        SewReport {
418            edges_sewn,
419            shells_merged,
420            open_edges_before,
421            open_edges_after,
422            oriented_outward,
423            issues,
424        },
425    ))
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::topology::VertexRecord;
432    use crate::{make_box_brep, make_cylinder_brep, solid_mass_properties, Vec4};
433
434    fn max_id(solid: &BrepSolid) -> u64 {
435        let vertex_max = solid.vertices.iter().map(|vertex| vertex.id).max();
436        let edge_max = solid.edges.iter().map(|edge| edge.id).max();
437        let face_max = solid
438            .shells
439            .iter()
440            .flat_map(|shell| &shell.faces)
441            .flat_map(|face| {
442                std::iter::once(face.id).chain(face.loops.iter().flat_map(|loop_record| {
443                    std::iter::once(loop_record.id)
444                        .chain(loop_record.coedges.iter().map(|coedge| coedge.id))
445                }))
446            })
447            .max();
448        [vertex_max, edge_max, face_max, Some(solid.id)]
449            .into_iter()
450            .flatten()
451            .max()
452            .unwrap_or(0)
453    }
454
455    /// Move the given faces into a NEW shell whose edges and vertices are
456    /// fresh duplicates — the un-sewn state an importer or a face-group
457    /// detachment produces.
458    fn detach_faces(solid: &BrepSolid, face_ids: &[u64]) -> BrepSolid {
459        let mut result = solid.clone();
460        let mut next = max_id(&result) + 1;
461        let mut moved = Vec::new();
462        for shell in &mut result.shells {
463            let mut kept = Vec::new();
464            for face in shell.faces.drain(..) {
465                if face_ids.contains(&face.id) {
466                    moved.push(face);
467                } else {
468                    kept.push(face);
469                }
470            }
471            shell.faces = kept;
472        }
473        assert_eq!(moved.len(), face_ids.len(), "all faces found");
474        let mut edge_map = HashMap::<u64, u64>::default();
475        let mut vertex_map = HashMap::<u64, u64>::default();
476        for face in &mut moved {
477            for coedge in face
478                .loops
479                .iter_mut()
480                .flat_map(|loop_record| &mut loop_record.coedges)
481            {
482                if let Some(&mapped) = edge_map.get(&coedge.edge_id) {
483                    coedge.edge_id = mapped;
484                    continue;
485                }
486                let mut duplicate = result
487                    .edges
488                    .iter()
489                    .find(|edge| edge.id == coedge.edge_id)
490                    .expect("edge exists")
491                    .clone();
492                for vertex_id in [&mut duplicate.start_vertex_id, &mut duplicate.end_vertex_id] {
493                    if let Some(&mapped) = vertex_map.get(vertex_id) {
494                        *vertex_id = mapped;
495                        continue;
496                    }
497                    let point = result
498                        .vertices
499                        .iter()
500                        .find(|vertex| vertex.id == *vertex_id)
501                        .expect("vertex exists")
502                        .point;
503                    result.vertices.push(VertexRecord { id: next, point });
504                    vertex_map.insert(*vertex_id, next);
505                    *vertex_id = next;
506                    next += 1;
507                }
508                duplicate.id = next;
509                next += 1;
510                edge_map.insert(coedge.edge_id, duplicate.id);
511                coedge.edge_id = duplicate.id;
512                result.edges.push(duplicate);
513            }
514        }
515        result.shells.push(ShellRecord {
516            id: next,
517            faces: moved,
518        });
519        // Originals whose every use moved (edges interior to the moved group)
520        // are now orphaned — purge them as a real detachment would.
521        let used_edges = result
522            .shells
523            .iter()
524            .flat_map(|shell| &shell.faces)
525            .flat_map(|face| &face.loops)
526            .flat_map(|loop_record| &loop_record.coedges)
527            .map(|coedge| coedge.edge_id)
528            .collect::<HashSet<_>>();
529        result.edges.retain(|edge| used_edges.contains(&edge.id));
530        let used_vertices = result
531            .edges
532            .iter()
533            .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
534            .collect::<HashSet<_>>();
535        result
536            .vertices
537            .retain(|vertex| used_vertices.contains(&vertex.id));
538        result
539    }
540
541    /// Translate every face surface, edge curve, and vertex belonging to the
542    /// given shell — its geometry is fully duplicated by `detach_faces`, so
543    /// nothing shared moves.
544    fn translate_shell(solid: &mut BrepSolid, shell_index: usize, delta: Vec3) {
545        let mut edge_ids = HashSet::default();
546        for face in &mut solid.shells[shell_index].faces {
547            for row in &mut face.surface.control_points {
548                for control in row.iter_mut() {
549                    let point = control.point().unwrap().add(delta);
550                    *control = Vec4::from_point(point, control.w);
551                }
552            }
553            for coedge in face
554                .loops
555                .iter_mut()
556                .flat_map(|loop_record| &mut loop_record.coedges)
557            {
558                edge_ids.insert(coedge.edge_id);
559            }
560        }
561        let mut vertex_ids = HashSet::default();
562        for edge in &mut solid.edges {
563            if !edge_ids.contains(&edge.id) {
564                continue;
565            }
566            for control in &mut edge.curve.control_points {
567                let point = control.point().unwrap().add(delta);
568                *control = Vec4::from_point(point, control.w);
569            }
570            vertex_ids.insert(edge.start_vertex_id);
571            vertex_ids.insert(edge.end_vertex_id);
572        }
573        for vertex in &mut solid.vertices {
574            if vertex_ids.contains(&vertex.id) {
575                vertex.point = vertex.point.add(delta);
576            }
577        }
578    }
579
580    fn one_use_count(solid: &BrepSolid) -> usize {
581        one_use_edge_ids(solid).len()
582    }
583
584    /// Faces whose sampled centroid x exceeds the split coordinate.
585    fn faces_beyond_x(solid: &BrepSolid, x: f64) -> Vec<u64> {
586        solid
587            .shells
588            .iter()
589            .flat_map(|shell| &shell.faces)
590            .filter(|face| {
591                face.surface
592                    .evaluate(0.5, 0.5)
593                    .map(|point| point.x > x)
594                    .unwrap_or(false)
595            })
596            .map(|face| face.id)
597            .collect()
598    }
599
600    #[test]
601    fn sews_detached_box_faces_into_watertight_solid() {
602        let solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
603        let moved = faces_beyond_x(&solid, 3.9); // the +x face
604        assert_eq!(moved.len(), 1);
605        let detached = detach_faces(&solid, &moved);
606        assert_eq!(detached.shells.len(), 2);
607        assert_eq!(one_use_count(&detached), 8, "4 rim edges duplicated");
608
609        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
610        assert_eq!(report.edges_sewn, 4, "{report:?}");
611        assert_eq!(report.shells_merged, 1);
612        assert_eq!(report.open_edges_after, 0);
613        assert!(report.issues.is_empty(), "{:?}", report.issues);
614        assert_eq!(sewn.shells.len(), 1);
615        assert_eq!(one_use_count(&sewn), 0);
616        let volume = solid_mass_properties(&sewn).unwrap().volume;
617        assert!((volume - 24.0).abs() < 1e-9, "{volume}");
618    }
619
620    #[test]
621    fn sews_a_flipped_component_and_orients_outward() {
622        let solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
623        let moved = faces_beyond_x(&solid, 3.9);
624        let mut detached = detach_faces(&solid, &moved);
625        // The detached component arrives with the opposite orientation
626        // convention — sewing must not require any pre-agreement.
627        crate::offset_shell::flip_shell_faces(&mut detached.shells[1]).unwrap();
628
629        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
630        assert_eq!(report.edges_sewn, 4, "{report:?}");
631        assert!(report.issues.is_empty(), "{:?}", report.issues);
632        assert_eq!(one_use_count(&sewn), 0);
633        let volume = solid_signed_volume(&sewn).unwrap();
634        assert!((volume - 24.0).abs() < 1e-9, "outward positive: {volume}");
635    }
636
637    #[test]
638    fn sews_a_detached_cap_along_its_closed_rim() {
639        let axis = Vec3::new(0.0, 0.0, 1.0);
640        let solid = make_cylinder_brep(Vec3::default(), axis, 1.5, 3.0).unwrap();
641        // Top cap: the face whose centroid sits highest along the axis.
642        let top = solid.shells[0]
643            .faces
644            .iter()
645            .max_by(|first, second| {
646                let height = |face: &FaceRecord| {
647                    face.surface
648                        .evaluate(0.5, 0.5)
649                        .map(|point| point.z)
650                        .unwrap_or(f64::NEG_INFINITY)
651                };
652                height(first).total_cmp(&height(second))
653            })
654            .unwrap()
655            .id;
656        let detached = detach_faces(&solid, &[top]);
657        assert_eq!(one_use_count(&detached), 2, "one closed rim duplicated");
658
659        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
660        assert_eq!(report.edges_sewn, 1, "{report:?}");
661        assert_eq!(report.open_edges_after, 0);
662        assert!(report.issues.is_empty(), "{:?}", report.issues);
663        assert_eq!(sewn.shells.len(), 1);
664        let volume = solid_mass_properties(&sewn).unwrap().volume;
665        let expected = std::f64::consts::PI * 1.5 * 1.5 * 3.0;
666        assert!((volume - expected).abs() < 1e-6, "{volume} vs {expected}");
667    }
668
669    #[test]
670    fn sews_a_multi_face_group_with_shared_corner_vertices() {
671        // Three faces meeting at a corner: their duplicated rims share
672        // duplicated vertices, so several sews must chain consistently.
673        let solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
674        let moved = solid.shells[0]
675            .faces
676            .iter()
677            .filter(|face| {
678                face.surface
679                    .evaluate(0.5, 0.5)
680                    .map(|point| point.x > 3.9 || point.y > 2.9 || point.z > 1.9)
681                    .unwrap_or(false)
682            })
683            .map(|face| face.id)
684            .collect::<Vec<_>>();
685        assert_eq!(moved.len(), 3);
686        let detached = detach_faces(&solid, &moved);
687
688        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
689        assert_eq!(report.open_edges_after, 0, "{report:?}");
690        assert!(report.issues.is_empty(), "{:?}", report.issues);
691        assert_eq!(sewn.shells.len(), 1);
692        let volume = solid_mass_properties(&sewn).unwrap().volume;
693        assert!((volume - 24.0).abs() < 1e-9, "{volume}");
694    }
695
696    #[test]
697    fn sews_reparametrized_duplicates_via_pcurve_refit() {
698        // The duplicated rim edges arrive with REVERSED curves (same locus,
699        // different parametrization): the fast keep-the-pcurve path cannot
700        // apply, so the rebind must refit pcurves and derive `forward` from
701        // the 3D walk alone.
702        let solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
703        let moved = faces_beyond_x(&solid, 3.9);
704        let mut detached = detach_faces(&solid, &moved);
705        let duplicated = detached.shells[1]
706            .faces
707            .iter()
708            .flat_map(|face| &face.loops)
709            .flat_map(|loop_record| &loop_record.coedges)
710            .map(|coedge| coedge.edge_id)
711            .collect::<HashSet<_>>();
712        for edge in &mut detached.edges {
713            if duplicated.contains(&edge.id) {
714                edge.curve = edge.curve.reversed().unwrap();
715                [edge.t0, edge.t1] = edge.curve.domain().unwrap();
716                std::mem::swap(&mut edge.start_vertex_id, &mut edge.end_vertex_id);
717            }
718        }
719
720        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
721        assert_eq!(report.edges_sewn, 4, "{report:?}");
722        assert_eq!(report.open_edges_after, 0);
723        assert!(report.issues.is_empty(), "{:?}", report.issues);
724        let volume = solid_mass_properties(&sewn).unwrap().volume;
725        assert!((volume - 24.0).abs() < 1e-9, "{volume}");
726    }
727
728    #[test]
729    fn sews_a_holed_face_with_ring_aware_genus() {
730        // Block with a vertical through-hole: the top face carries an INNER
731        // loop (the hole rim). Detaching and re-sewing it exercises closed-rim
732        // pairing on a ringed face and the ring-aware (V−E+F−H) genus
733        // recompute — an outer-loops-only Euler count would corrupt genus
734        // here and trip validation.
735        let block = make_box_brep(Vec3::default(), 4.0, 4.0, 2.0).unwrap();
736        let drill = make_cylinder_brep(
737            Vec3::new(2.0, 2.0, -1.0),
738            Vec3::new(0.0, 0.0, 1.0),
739            1.0,
740            4.0,
741        )
742        .unwrap();
743        let holed = crate::boolean_operation(
744            &block,
745            &drill,
746            crate::BooleanOperation::Subtract,
747            &crate::BooleanOptions::default(),
748        )
749        .unwrap();
750        let top = holed
751            .shells
752            .iter()
753            .flat_map(|shell| &shell.faces)
754            .find(|face| {
755                face.loops.len() > 1
756                    && face
757                        .surface
758                        .evaluate(0.05, 0.05)
759                        .map(|point| point.z > 1.9)
760                        .unwrap_or(false)
761            })
762            .expect("holed top face")
763            .id;
764        let detached = detach_faces(&holed, &[top]);
765
766        let (sewn, report) = sew_solid(&detached, 1e-6).unwrap();
767        assert_eq!(report.open_edges_after, 0, "{report:?}");
768        assert!(report.issues.is_empty(), "{:?}", report.issues);
769        assert_eq!(sewn.shells.len(), 1);
770        let expected = 4.0 * 4.0 * 2.0 - std::f64::consts::PI * 2.0;
771        let volume = solid_mass_properties(&sewn).unwrap().volume;
772        assert!((volume - expected).abs() < 1e-6, "{volume} vs {expected}");
773    }
774
775    #[test]
776    fn watertight_input_is_a_no_op() {
777        let solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
778        let (sewn, report) = sew_solid(&solid, 1e-6).unwrap();
779        assert_eq!(report.edges_sewn, 0);
780        assert_eq!(report.shells_merged, 0);
781        assert_eq!(report.open_edges_before, 0);
782        assert_eq!(report.open_edges_after, 0);
783        assert!(report.issues.is_empty(), "{:?}", report.issues);
784        let volume = solid_mass_properties(&sewn).unwrap().volume;
785        assert!((volume - 24.0).abs() < 1e-9, "{volume}");
786    }
787
788    #[test]
789    fn reports_an_unsewable_gap_honestly() {
790        let solid = make_box_brep(Vec3::default(), 4.0, 3.0, 2.0).unwrap();
791        let moved = faces_beyond_x(&solid, 3.9);
792        let mut detached = detach_faces(&solid, &moved);
793        translate_shell(&mut detached, 1, Vec3::new(0.5, 0.0, 0.0));
794
795        let open_before = one_use_count(&detached);
796        let (sewn, report) = sew_solid(&detached, 1e-4).unwrap();
797        assert_eq!(report.edges_sewn, 0, "{report:?}");
798        assert_eq!(report.shells_merged, 0);
799        assert_eq!(report.open_edges_after, open_before);
800        assert_eq!(sewn.shells.len(), 2, "nothing force-welded");
801    }
802}
803
804/// Split PINCHED vertices — points where two (or more) umbrella fans of
805/// faces meet at a single vertex record. Local manifold checks (edge use
806/// counts, loop closure, orientation) cannot see a pinch; it surfaces only
807/// as an odd Euler characteristic. The link of a manifold boundary vertex is
808/// a single edge-connected fan: union incident edges through every loop
809/// CORNER at the vertex (consecutive coedges meeting there inside one face);
810/// more than one component means distinct fans sharing the record — give
811/// each extra fan its own vertex at the same point and reassign that fan's
812/// edge endpoints. Geometry is untouched; only identity is repaired.
813pub fn split_pinched_vertices(solid: &mut BrepSolid) -> Result<usize, String> {
814    let mut split_count = 0usize;
815    let vertex_ids: Vec<u64> = solid.vertices.iter().map(|vertex| vertex.id).collect();
816    let mut next_id = solid
817        .vertices
818        .iter()
819        .map(|vertex| vertex.id)
820        .chain(solid.edges.iter().map(|edge| edge.id))
821        .max()
822        .unwrap_or(0)
823        + 1;
824    // id -> index maps built once. This routine only mutates edge endpoints
825    // in place and appends vertices, so indices never shift: every lookup
826    // returns the identical live record the previous `.iter().find(id==)`
827    // scans returned. Kills the O(V*coedges*E) nested edge find below.
828    let edge_of_id: HashMap<u64, usize> = solid
829        .edges
830        .iter()
831        .enumerate()
832        .map(|(index, edge)| (edge.id, index))
833        .collect();
834    let vertex_of_id: HashMap<u64, usize> = solid
835        .vertices
836        .iter()
837        .enumerate()
838        .map(|(index, vertex)| (vertex.id, index))
839        .collect();
840    for vertex_id in vertex_ids {
841        // Incident edges (either endpoint; closed and degenerate included so
842        // pole/seam structures stay connected through their corners).
843        let incident: Vec<u64> = solid
844            .edges
845            .iter()
846            .filter(|edge| edge.start_vertex_id == vertex_id || edge.end_vertex_id == vertex_id)
847            .map(|edge| edge.id)
848            .collect();
849        if incident.len() < 4 {
850            // A pinch needs at least two fans of >= 2 edges each.
851            continue;
852        }
853        let index_of: HashMap<u64, usize> = incident
854            .iter()
855            .enumerate()
856            .map(|(index, id)| (*id, index))
857            .collect();
858        let mut parent: Vec<usize> = (0..incident.len()).collect();
859        fn root(parent: &mut [usize], index: usize) -> usize {
860            if parent[index] != index {
861                parent[index] = root(parent, parent[index]);
862            }
863            parent[index]
864        }
865        let edge_end = |edge_id: u64, forward: bool| -> Option<u64> {
866            edge_of_id.get(&edge_id).map(|&index| {
867                let edge = &solid.edges[index];
868                if forward {
869                    edge.end_vertex_id
870                } else {
871                    edge.start_vertex_id
872                }
873            })
874        };
875        for shell in &solid.shells {
876            for face in &shell.faces {
877                for loop_record in &face.loops {
878                    let count = loop_record.coedges.len();
879                    for index in 0..count {
880                        let current = &loop_record.coedges[index];
881                        let next = &loop_record.coedges[(index + 1) % count];
882                        // The corner between current and next sits at
883                        // current's traversal END vertex.
884                        let Some(junction) = edge_end(current.edge_id, current.forward) else {
885                            continue;
886                        };
887                        if junction != vertex_id {
888                            continue;
889                        }
890                        let (Some(&a), Some(&b)) =
891                            (index_of.get(&current.edge_id), index_of.get(&next.edge_id))
892                        else {
893                            continue;
894                        };
895                        let ra = root(&mut parent, a);
896                        let rb = root(&mut parent, b);
897                        if ra != rb {
898                            parent[rb] = ra;
899                        }
900                    }
901                }
902            }
903        }
904        let mut component_of: HashMap<usize, usize> = HashMap::default();
905        let mut components = 0usize;
906        let mut assignment: Vec<usize> = vec![0; incident.len()];
907        for index in 0..incident.len() {
908            let r = root(&mut parent, index);
909            let component = *component_of.entry(r).or_insert_with(|| {
910                components += 1;
911                components - 1
912            });
913            assignment[index] = component;
914        }
915        if components < 2 {
916            continue;
917        }
918        // Keep the original record for component 0; every further fan gets a
919        // duplicate vertex at the same point.
920        let point = vertex_of_id
921            .get(&vertex_id)
922            .map(|&index| solid.vertices[index].point)
923            .ok_or("split_pinched_vertices: vertex vanished")?;
924        let mut replacement_ids = vec![vertex_id];
925        for _ in 1..components {
926            let id = next_id;
927            next_id += 1;
928            solid.vertices.push(VertexRecord { id, point });
929            replacement_ids.push(id);
930        }
931        for (offset, edge_id) in incident.iter().enumerate() {
932            let replacement = replacement_ids[assignment[offset]];
933            if replacement == vertex_id {
934                continue;
935            }
936            if let Some(edge) = solid.edges.iter_mut().find(|edge| edge.id == *edge_id) {
937                if edge.start_vertex_id == vertex_id {
938                    edge.start_vertex_id = replacement;
939                }
940                if edge.end_vertex_id == vertex_id {
941                    edge.end_vertex_id = replacement;
942                }
943            }
944        }
945        split_count += components - 1;
946    }
947    Ok(split_count)
948}
949
950#[cfg(test)]
951mod pinch_tests {
952    use super::*;
953    use crate::make_box_brep;
954
955    #[test]
956    fn a_hand_welded_corner_pinch_splits_back_to_manifold() {
957        // Two boxes meeting at exactly one corner point; welding that corner
958        // into ONE vertex record creates the pinch (χ drops odd), and the
959        // splitter must give each box its own vertex back.
960        let first = make_box_brep(Vec3::default(), 2.0, 2.0, 2.0).unwrap();
961        let second = make_box_brep(Vec3::new(2.0, 2.0, 2.0), 2.0, 2.0, 2.0).unwrap();
962        // Merge into one two-shell solid with disjoint id spaces.
963        let mut solid = first.clone();
964        let offset = 1000u64;
965        let mut moved = second.clone();
966        for vertex in &mut moved.vertices {
967            vertex.id += offset;
968        }
969        for edge in &mut moved.edges {
970            edge.id += offset;
971            edge.start_vertex_id += offset;
972            edge.end_vertex_id += offset;
973        }
974        for shell in &mut moved.shells {
975            shell.id += offset;
976            for face in &mut shell.faces {
977                face.id += offset;
978                for loop_record in &mut face.loops {
979                    loop_record.id += offset;
980                    for coedge in &mut loop_record.coedges {
981                        coedge.id += offset;
982                        coedge.edge_id += offset;
983                    }
984                }
985            }
986        }
987        solid.vertices.extend(moved.vertices);
988        solid.edges.extend(moved.edges);
989        solid.shells.extend(moved.shells);
990
991        // Find the two coincident corner vertices at (2,2,2) and weld them.
992        let corner = Vec3::new(2.0, 2.0, 2.0);
993        let ids: Vec<u64> = solid
994            .vertices
995            .iter()
996            .filter(|vertex| vertex.point.sub(corner).length() < 1e-9)
997            .map(|vertex| vertex.id)
998            .collect();
999        assert_eq!(ids.len(), 2, "both corner vertices present");
1000        let (keep, remove) = (ids[0], ids[1]);
1001        for edge in &mut solid.edges {
1002            if edge.start_vertex_id == remove {
1003                edge.start_vertex_id = keep;
1004            }
1005            if edge.end_vertex_id == remove {
1006                edge.end_vertex_id = keep;
1007            }
1008        }
1009        solid.vertices.retain(|vertex| vertex.id != remove);
1010
1011        // The welded solid is pinched: local checks pass but χ is odd.
1012        let split = split_pinched_vertices(&mut solid).unwrap();
1013        assert_eq!(split, 1, "exactly one fan duplicated");
1014        assert_eq!(
1015            solid
1016                .vertices
1017                .iter()
1018                .filter(|vertex| vertex.point.sub(corner).length() < 1e-9)
1019                .count(),
1020            2,
1021            "corner vertices restored"
1022        );
1023        // Each shell is a clean box again.
1024        assert!(solid.validate().is_empty(), "{:?}", solid.validate());
1025    }
1026
1027    #[test]
1028    fn manifold_solids_are_untouched() {
1029        let mut solid = make_box_brep(Vec3::default(), 2.0, 2.0, 2.0).unwrap();
1030        let before = solid.vertices.len();
1031        assert_eq!(split_pinched_vertices(&mut solid).unwrap(), 0);
1032        assert_eq!(solid.vertices.len(), before);
1033    }
1034}