fix: join connected solids at exact edge contacts

This commit is contained in:
bherbruck committed 2026-10-10 23:11:02 -04:00
1 parent fc8b71956c
commit 22672585c9
3 files changed
+222 -10

No files matched your search

+1 -1
View File
@@ -511,7 +511,7 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>],
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<Result<Option<Body>>> {
+205 -6
View File
@@ -65,24 +65,24 @@ fn key(p: Vec3) -> Key {
#[derive(Default)]
struct Cache {
verts: HashMap<Key, mt::Vertex>,
edges: HashMap<(Key, Key, Option<Key>), mt::Edge>,
edges: HashMap<(Key, Key, Option<Key>, 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<Body> {
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<Body> {
sew_impl(faces, true)
}
type EdgeUse = (usize, usize, usize);
fn touching_sheets(faces: &[FaceSpec]) -> Result<HashMap<EdgeUse, usize>> {
type Use = (EdgeUse, Vec3, Vec3, Vec3);
let mut uses: HashMap<(Key, Key), Vec<Use>> = 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<mt::EdgeID, Vec<usize>> = 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<mt::Face>) -> Result<Vec<mt::Face>> {
let mut by_vertex: HashMap<mt::VertexID, Vec<usize>> = HashMap::new();
let mut by_edge: HashMap<mt::EdgeID, Vec<usize>> = 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<mt::Wire> = 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::<Result<Vec<_>>>()
.map(Into::into)
})
.collect::<Result<_>>()?;
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<Body> {
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<Body> {
}
}
guard("sew", || {
let sheets = if touching { touching_sheets(faces)? } else { HashMap::new() };
let mut cache = Cache::default();
let mut out: Vec<mt::Face> = Vec::with_capacity(faces.len());
for (fi, f) in faces.iter().enumerate() {
let wires: Vec<mt::Wire> = f.loops.iter().map(|l| l.iter().map(|e| cache.edge(e)).collect::<Vec<_>>().into()).collect();
let wires: Vec<mt::Wire> = 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::<Vec<_>>().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<Body> {
};
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)
+16 -3
View File
@@ -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();