From 22672585c9dff977de24887d37e1b43c3ba4ea54 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sat, 10 Oct 2026 23:11:02 -0400 Subject: [PATCH] fix: join connected solids at exact edge contacts --- crates/kernel/src/prism.rs | 2 +- crates/kernel/src/sew.rs | 211 +++++++++++++++++++++++++++++++++++-- crates/kernel/src/tests.rs | 19 +++- 3 files changed, 222 insertions(+), 10 deletions(-) diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 3af72983..2c29606f 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -511,7 +511,7 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec], faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None }); } } - Some(crate::sew::sew(&faces)) + Some(crate::sew::sew_touching(&faces)) } fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { diff --git a/crates/kernel/src/sew.rs b/crates/kernel/src/sew.rs index e210ec0f..cfcfe181 100644 --- a/crates/kernel/src/sew.rs +++ b/crates/kernel/src/sew.rs @@ -65,24 +65,24 @@ fn key(p: Vec3) -> Key { #[derive(Default)] struct Cache { verts: HashMap, - edges: HashMap<(Key, Key, Option), mt::Edge>, + edges: HashMap<(Key, Key, Option, usize), mt::Edge>, } impl Cache { fn vertex(&mut self, p: Vec3) -> mt::Vertex { self.verts.entry(key(p)).or_insert_with(|| builder::vertex(p3(p))).clone() } - fn edge(&mut self, e: &EdgeSpec) -> mt::Edge { + fn edge(&mut self, e: &EdgeSpec, sheet: usize) -> mt::Edge { let (a, b) = e.ends(); let mid = match e { EdgeSpec::Arc { mid, .. } => Some(key(*mid)), EdgeSpec::Line { .. } => None, }; let (ka, kb) = (key(a), key(b)); - if let Some(found) = self.edges.get(&(ka, kb, mid)) { + if let Some(found) = self.edges.get(&(ka, kb, mid, sheet)) { return found.clone(); } - if let Some(found) = self.edges.get(&(kb, ka, mid)) { + if let Some(found) = self.edges.get(&(kb, ka, mid, sheet)) { return found.inverse(); } let (va, vb) = (self.vertex(a), self.vertex(b)); @@ -90,13 +90,198 @@ impl Cache { EdgeSpec::Line { .. } => builder::line(&va, &vb), EdgeSpec::Arc { mid, .. } => builder::circle_arc(&va, &vb, p3(mid)), }; - self.edges.insert((ka, kb, mid), made.clone()); + self.edges.insert((ka, kb, mid, sheet), made.clone()); made } } /// Sew the faces into a closed solid. pub fn sew(faces: &[FaceSpec]) -> Result { + sew_impl(faces, false) +} + +/// Separate boundary sheets at a shared geometric edge without moving it. +/// The face adjacency must still describe one connected solid boundary. +pub(crate) fn sew_touching(faces: &[FaceSpec]) -> Result { + sew_impl(faces, true) +} + +type EdgeUse = (usize, usize, usize); + +fn touching_sheets(faces: &[FaceSpec]) -> Result> { + type Use = (EdgeUse, Vec3, Vec3, Vec3); + let mut uses: HashMap<(Key, Key), Vec> = HashMap::new(); + for (fi, face) in faces.iter().enumerate() { + let Some(outer) = face.loops.first() else { continue }; + let normal = outer + .iter() + .fold(Vec3::ZERO, |n, e| { + let (a, b) = e.ends(); + n + a.cross(b) + }) + .normalized(); + for (li, lp) in face.loops.iter().enumerate() { + for (ei, e) in lp.iter().enumerate() { + if matches!(e, EdgeSpec::Line { .. }) && matches!(face.surface, SurfSpec::Plane) { + let (a, b) = e.ends(); + let (ka, kb) = (key(a), key(b)); + let k = if ka < kb { (ka, kb) } else { (kb, ka) }; + if let Some(n) = normal { + uses.entry(k).or_default().push(((fi, li, ei), a, b, n)); + } + } + } + } + } + let mut sheets = HashMap::new(); + for group in uses.values().filter(|g| g.len() > 2) { + if group.len() != 4 { + return Err(KernelError::Invalid("sew: unsupported edge contact".into())); + } + let mut paired = std::collections::HashSet::new(); + for (i, (use_a, a, b, n)) in group.iter().enumerate() { + if paired.contains(&i) { + continue; + } + let t = (*b - *a).normalized().ok_or_else(|| KernelError::Invalid("sew: a degenerate contact".into()))?; + // Pair the two faces bounding each material wedge, not the empty + // wedge between them. Each pair uses the edge in opposite directions. + let j = group + .iter() + .enumerate() + .find_map(|(j, (_, c, d, m))| { + (i != j + && !paired.contains(&j) + && key(*a) == key(*d) + && key(*b) == key(*c) + && n.cross(t).dot(*m) < -1e-8 + && m.cross(-t).dot(*n) < -1e-8) + .then_some(j) + }) + .ok_or_else(|| KernelError::Invalid("sew: ambiguous edge contact".into()))?; + let use_b = group.get(j).ok_or_else(|| KernelError::Invalid("sew: edge contact".into()))?.0; + let sheet = paired.len() / 2; + sheets.insert(*use_a, sheet); + sheets.insert(use_b, sheet); + paired.insert(i); + paired.insert(j); + } + } + Ok(sheets) +} + +fn faces_connected(faces: &[mt::Face]) -> bool { + let mut by_edge: HashMap> = HashMap::new(); + for (i, f) in faces.iter().enumerate() { + for e in f.edge_iter() { + by_edge.entry(e.id()).or_default().push(i); + } + } + let mut seen = std::collections::HashSet::new(); + let mut todo = vec![0]; + while let Some(i) = todo.pop() { + if seen.insert(i) + && let Some(f) = faces.get(i) + { + for e in f.edge_iter() { + if let Some(adjacent) = by_edge.get(&e.id()) { + todo.extend(adjacent.iter().copied().filter(|j| !seen.contains(j))); + } + } + } + } + seen.len() == faces.len() +} + +/// A contact's edge sheets can terminate in separate vertex fans. Give each fan +/// its own vertex at the same coordinates so the boundary remains a manifold. +fn split_vertex_fans(faces: Vec) -> Result> { + let mut by_vertex: HashMap> = HashMap::new(); + let mut by_edge: HashMap> = HashMap::new(); + let mut vertices = HashMap::new(); + for (i, f) in faces.iter().enumerate() { + for v in f.vertex_iter() { + vertices.insert(v.id(), v.clone()); + by_vertex.entry(v.id()).or_default().push(i); + } + for e in f.edge_iter() { + by_edge.entry(e.id()).or_default().push(i); + } + } + let mut replacements: HashMap<(mt::VertexID, usize), mt::Vertex> = HashMap::new(); + for (id, members) in &mut by_vertex { + members.sort_unstable(); + members.dedup(); + let mut seen = std::collections::HashSet::new(); + let mut fans = Vec::new(); + for start in members.iter().copied() { + if seen.contains(&start) { + continue; + } + let mut todo = vec![start]; + let mut fan = Vec::new(); + while let Some(i) = todo.pop() { + if !seen.insert(i) { + continue; + } + fan.push(i); + if let Some(f) = faces.get(i) { + for e in f.edge_iter().filter(|e| e.absolute_front().id() == *id || e.absolute_back().id() == *id) { + if let Some(adjacent) = by_edge.get(&e.id()) { + todo.extend(adjacent.iter().copied().filter(|j| !seen.contains(j))); + } + } + } + } + fans.push(fan); + } + if fans.len() > 1 { + let original = vertices.get(id).ok_or_else(|| KernelError::Failed("sew: missing contact vertex".into()))?; + for fan in fans { + let vertex = builder::vertex(original.point()); + for i in fan { + replacements.insert((*id, i), vertex.clone()); + } + } + } + } + let mut edges = HashMap::new(); + for (i, f) in faces.iter().enumerate() { + for e in f.edge_iter() { + edges.entry(e.id()).or_insert_with(|| { + let (a, b) = (e.absolute_front(), e.absolute_back()); + let a = replacements.get(&(a.id(), i)).unwrap_or(a); + let b = replacements.get(&(b.id(), i)).unwrap_or(b); + mt::Edge::new(a, b, e.curve()) + }); + } + } + faces + .into_iter() + .map(|f| { + let wires: Vec = f + .absolute_boundaries() + .iter() + .map(|w| { + w.edge_iter() + .map(|e| { + let rebuilt = edges.get(&e.id()).ok_or_else(|| KernelError::Failed("sew: missing contact edge".into()))?; + Ok(if e.front() == e.absolute_front() { rebuilt.clone() } else { rebuilt.inverse() }) + }) + .collect::>>() + .map(Into::into) + }) + .collect::>()?; + let mut rebuilt = mt::Face::try_new(wires, f.surface()).map_err(|e| KernelError::Failed(format!("sew: contact face: {e}")))?; + if !f.orientation() { + rebuilt.invert(); + } + Ok(rebuilt) + }) + .collect() +} + +fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result { if faces.len() < 4 || faces.len() > 200_000 { return Err(KernelError::Invalid("a solid needs 4…200000 faces".into())); } @@ -111,10 +296,18 @@ pub fn sew(faces: &[FaceSpec]) -> Result { } } guard("sew", || { + let sheets = if touching { touching_sheets(faces)? } else { HashMap::new() }; let mut cache = Cache::default(); let mut out: Vec = Vec::with_capacity(faces.len()); for (fi, f) in faces.iter().enumerate() { - let wires: Vec = f.loops.iter().map(|l| l.iter().map(|e| cache.edge(e)).collect::>().into()).collect(); + let wires: Vec = f + .loops + .iter() + .enumerate() + .map(|(li, l)| { + l.iter().enumerate().map(|(ei, e)| cache.edge(e, sheets.get(&(fi, li, ei)).copied().unwrap_or(0))).collect::>().into() + }) + .collect(); let face = match f.surface { SurfSpec::Plane => builder::try_attach_plane(&wires).map_err(|e| KernelError::Failed(format!("sew: face {fi}: {e}")))?, SurfSpec::Revolved { a, b, axis_origin, axis, angle } => { @@ -132,6 +325,12 @@ pub fn sew(faces: &[FaceSpec]) -> Result { }; out.push(face); } + if !sheets.is_empty() && !faces_connected(&out) { + return Err(KernelError::Invalid("sew: bodies touch only along an edge".into())); + } + if !sheets.is_empty() { + out = split_vertex_fans(out)?; + } let shell: mt::Shell = out.into(); let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?; Body::new(solid) diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 30144e1f..b65c41a5 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -318,7 +318,7 @@ fn planar_booleans_with_coincident_faces() { } #[test] -fn stepped_logo_join_never_moves_faces_to_hide_an_edge_pinch() { +fn connected_logo_join_preserves_exact_faces_at_edge_contact() { let mut body = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 10.0)).unwrap(); for (i, (lo, hi)) in [(Vec3::new(0.0, 0.0, 10.0), Vec3::new(10.0, 10.0, 20.0)), (Vec3::new(-10.0, 0.0, 10.0), Vec3::new(0.0, 10.0, 20.0))].into_iter().enumerate() @@ -336,8 +336,21 @@ fn stepped_logo_join_never_moves_faces_to_hide_an_edge_pinch() { assert!(m.bbox.max.dist(Vec3::new(10.0, 20.0, 20.0)) < 1e-9); } let tool = box_solid(Vec3::new(-10.0, 10.0, 10.0), Vec3::new(0.0, 20.0, 20.0)).unwrap(); - let error = boolean(&body, &tool, BoolOp::Union).unwrap_err(); - assert!(error.to_string().contains("zero-thickness edge contact"), "{error}"); + let logo = boolean(&body, &tool, BoolOp::Union).unwrap().unwrap(); + assert!(logo.validity().is_empty()); + let m = measure(&logo).unwrap(); + assert!((m.volume - 5000.0).abs() < 1e-6); + assert!(m.bbox.min.dist(Vec3::new(-10.0, 0.0, 0.0)) < 1e-9); + assert!(m.bbox.max.dist(Vec3::new(10.0, 20.0, 20.0)) < 1e-9); + let tops = + logo.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_some_and(|n| n.z > 0.999999) && (f.centroid.z - 20.0).abs() < 1e-9).count(); + assert_eq!(tops, 1); + let text = step_export(&[&logo], "SolveCraft").unwrap(); + let imported = step_import(&text).unwrap(); + assert_eq!(imported.bodies.len(), 1); + let roundtrip = &imported.bodies.first().unwrap().body; + assert!(roundtrip.validity().is_empty()); + assert!((measure(roundtrip).unwrap().volume - 5000.0).abs() < 1e-6); // An intentional overlap removes the pinch without any automatic displacement. let overlap = box_solid(Vec3::new(-10.0, 10.0, 10.0), Vec3::new(0.1, 20.0, 20.0)).unwrap(); let joined = boolean(&body, &overlap, BoolOp::Union).unwrap().unwrap();