1use 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
59fn 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
73fn 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
94fn 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
106struct RebindPatch {
109 shell: usize,
110 face: usize,
111 loop_index: usize,
112 coedge: usize,
113 forward: bool,
114 pcurve: Option<NurbsCurve>,
115}
116
117fn 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 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
236fn 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 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 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
295fn 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
349enum PairSearch {
350 Hashed,
353 #[cfg_attr(not(test), allow(dead_code))]
355 Exhaustive,
356}
357
358fn 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 if !scaled.is_finite() || scaled.abs() > 9.0e15 {
373 return None;
374 }
375 *slot = scaled as i64;
376 }
377 Some(key)
378}
379
380struct EndpointGrid {
393 cell: f64,
394 buckets: HashMap<[i64; 3], Vec<usize>>,
395}
396
397impl EndpointGrid {
398 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 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#[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
518pub 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
531fn 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 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 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 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 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 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 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 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 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 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 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 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); 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 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 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 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 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 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 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 struct SeamRotatedCap {
1148 solid: BrepSolid,
1149 rim_id: u64,
1151 original_seam: Vec3,
1153 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 edge.start_vertex_id = 9_999_999;
1713 }
1714 }
1715 detached
1716 };
1717
1718 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 #[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 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 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 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 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 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
1857pub 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 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 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 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 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(¤t.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 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 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 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 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 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 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}