fix: join connected solids at exact edge contacts
This commit is contained in:
1 parent
fc8b71956c
commit
22672585c9
3 files changed
+222
-10
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user