fix: fuse a loft standing on a face instead of returning touching shells (#577)

Fixes #396. A union that would come back as shells touching over an area is rejected and the face-in-face case is fused exactly; line and point contact stay as before.
This commit is contained in:
Kevin Ravensberg authored and GitHub committed 2026-10-13 06:04:41 -04:00
1 parent bb60a95289
commit 24cff445ad
5 files changed
+245 -2

No files matched your search

@@ -603,6 +603,59 @@ fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically
}
}
/// The design from #396: a loft from a Ø12 circle on a 40 × 30 × 20 box's top to a hexagon 15 up.
fn loft_on_box(op: &str) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [20, 15]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 20, "body_name": "Box"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 20, "name": "A"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 35, "name": "B"}));
run(&mut s, "sketch.create", json!({"plane": "A", "name": "L0"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "diameter": 12}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "B", "name": "L1"}));
run(&mut s, "sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 4, "sides": 6}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "L0"}, {"sketch": "L1"}], "operation": op}));
s
}
#[test]
fn loft_joined_onto_a_box_exports_one_connected_mesh() {
// #396: one body, but its two shells only touched, and the STL came out in two pieces.
let mut apart = loft_on_box("new_body");
let m = measure(&mut apart);
assert_eq!(m["body_count"], 2);
let loft = m["bodies"][1]["volume_mm3"].as_f64().unwrap();
let mut s = loft_on_box("join");
let m = measure(&mut s);
assert_eq!(m["body_count"], 1);
assert_eq!(m["bodies"][0]["kernel"]["shells"], 1);
let v = volume(&mut s);
assert!(rel(v, 24000.0 + loft) < 1e-5, "{v} vs {}", 24000.0 + loft);
// The exported mesh, its vertices merged, is one connected piece.
let mesh = s.model.state().body("Box").unwrap().body.tessellate(0.01).unwrap();
let (positions, triangles) = solidcraft_io::weld(&mesh.positions, &mesh.triangles, 1e-4);
let mut piece: Vec<usize> = (0..positions.len()).collect();
let root = |piece: &mut Vec<usize>, mut i: usize| {
while piece[i] != i {
i = piece[i];
}
i
};
for t in &triangles {
let r0 = root(&mut piece, t[0] as usize);
for k in [1, 2] {
let rk = root(&mut piece, t[k] as usize);
piece[rk] = r0;
}
}
let pieces: std::collections::BTreeSet<usize> = triangles.iter().map(|t| root(&mut piece, t[0] as usize)).collect();
assert_eq!(pieces.len(), 1);
}
/// A cone that takes only a 1 mm chamfer off a cylinder's top edge leaves nearly all of the
/// cylinder inside it; the intersection is still the chamfered cylinder (#443).
#[test]
+6
View File
@@ -1159,6 +1159,12 @@ impl Body {
self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3))
}
/// A union's pieces must be clear of each other by the contact distance of [`Body::meets`];
/// closer shells are touching material that was never fused (#396).
pub(crate) fn union_lumps(&self) -> Result<Vec<Body>> {
self.lumps_with_clearance(2.0 * (self.size() * 1e-4).max(1e-6))
}
fn lumps_with_clearance(&self, clearance: f64) -> Result<Vec<Body>> {
if self.mesh.is_some() || self.solid.boundaries().len() < 2 {
return Ok(vec![self.clone()]);
+123
View File
@@ -424,6 +424,30 @@ fn trimmed(x: &Body, h: History, slab: &Body, operands: [&Body; 2], n: Vec3, d:
Some(cut)
}
/// Do the bodies touch over an area, back to back on a common plane: has `a` material just
/// behind some part of a face of `b` lying in a plane of a face of `a` that faces the other way?
/// (Contact along a line or at a point is not this.)
pub(crate) fn faces_touch(a: &Body, b: &Body) -> bool {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
let pa = planes(a);
let faces: Vec<(usize, Vec3)> = planes(b)
.into_iter()
.filter(|(_, y)| pa.iter().any(|(_, x)| x.n.dot(y.n) < -1.0 + 1e-9 && (x.d + y.d).abs() < tol))
.map(|(i, y)| (i, y.n))
.collect();
if faces.is_empty() {
return false;
}
let (Ok(ma), Ok(mb)) = (a.tessellate_with(size * 1e-3, true), b.tessellate_with(size * 1e-3, true)) else { return false };
let inside = ma.inside_index();
let depth = (size * 1e-4).max(1e-6);
mb.triangles.iter().zip(&mb.tri_face).take(100_000).any(|(t, fi)| {
let Some((_, n)) = faces.iter().find(|(i, _)| *i == *fi as usize) else { return false };
mb.tri(t).is_some_and(|[p, q, r]| inside.contains((p + q + r) / 3.0 + *n * depth))
})
}
/// Has `a` material behind every part of b's faces in the plane (m, d) (m their outward
/// normal, pointing into `a`), to `depth`? Checked at points inside the faces' triangles.
fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
@@ -562,6 +586,105 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
None
}
/// Union of bodies touching back to back where a planar face of one lies wholly inside a
/// coincident face of the other, clear of its edges (a loft or revolve standing on a box top,
/// #396): the inner face goes, and the outer face takes its outline as a hole, sharing its
/// edges. `None` when no such pair of faces exists. The caller checks that the bodies don't
/// also overlap elsewhere.
pub(crate) fn inset(a: &Body, b: &Body) -> Option<Result<Body>> {
inset_into(a, b, 0).or_else(|| inset_into(b, a, 1))
}
/// [`inset`] with a face of `inner` inside one of `outer`, `outer` being operand `outer_operand`.
fn inset_into(outer: &Body, inner: &Body, outer_operand: usize) -> Option<Result<Body>> {
use mt::{BoundedCurve, ParametricCurve};
const SAMPLES: usize = 64;
const MAX_WORK: usize = 4_000_000;
let size = outer.size().max(inner.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
// Clear of the outer face's edges by more than the samples' chord error.
let clear = (size * 2e-3).max(1e-6);
let sample = |w: &mt::Wire| -> Option<Vec<Vec3>> {
if w.len() > 1000 {
return None;
}
let mut pts = Vec::with_capacity(w.len() * SAMPLES);
for e in w.edge_iter() {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
pts.extend((0..SAMPLES).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / SAMPLES as f64))));
}
(!pts.is_empty() && pts.iter().all(|p| p.is_finite())).then_some(pts)
};
let fo: Vec<&mt::Face> = outer.solid.face_iter().collect();
let fi: Vec<&mt::Face> = inner.solid.face_iter().collect();
for (io, x) in &planes(outer) {
for (ii, y) in &planes(inner) {
if x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
continue;
}
let (Some(face_o), Some(face_i)) = (fo.get(*io), fi.get(*ii)) else { continue };
let outline = match face_i.boundaries().as_slice() {
[w] => w.clone(),
_ => continue,
};
let (Some(inner_pts), Some(outer_loops)) = (sample(&outline), face_o.boundaries().iter().map(sample).collect::<Option<Vec<_>>>()) else {
continue;
};
let outer_count: usize = outer_loops.iter().map(Vec::len).sum();
if inner_pts.len().saturating_mul(outer_count) > MAX_WORK {
continue;
}
let u = x.n.any_perp();
let w = x.n.cross(u);
let flat = |pts: &[Vec3]| {
solidcraft_geom::Loop2::polygon(&pts.iter().map(|p| solidcraft_geom::Vec2::new(p.dot(u), p.dot(w))).collect::<Vec<_>>())
};
let flat_point = |p: Vec3| solidcraft_geom::Vec2::new(p.dot(u), p.dot(w));
let outer_polys: Vec<_> = outer_loops.iter().map(|l| flat(l)).collect();
let inner_poly = flat(&inner_pts);
let in_outer = |p: Vec3| outer_polys.iter().filter(|l| l.contains(flat_point(p))).count() % 2 == 1;
let near_outer =
|p: Vec3| outer_loops.iter().any(|l| l.iter().zip(l.iter().cycle().skip(1)).any(|(s, t)| p.dist_to_segment(*s, *t) < clear));
let fits = inner_pts.iter().all(|p| in_outer(*p) && !near_outer(*p))
&& outer_loops.iter().flatten().all(|p| !inner_poly.contains(flat_point(*p)));
if !fits {
continue;
}
return Some(guard("inset", || {
let fail = |m: &str| KernelError::Failed(format!("joining a face standing inside another: {m}"));
// The outline runs round the inner face's outward normal, the reverse of the
// outer face's: the way a hole in the outer face runs.
let mut wires = face_o.absolute_boundaries().clone();
wires.push(if face_o.orientation() { outline.clone() } else { outline.inverse() });
let mut holed = mt::Face::try_new(wires, face_o.surface()).map_err(|e| fail(&e.to_string()))?;
if !face_o.orientation() {
holed.invert();
}
let inner_operand = 1 - outer_operand;
let mut faces = Vec::with_capacity(fo.len() + fi.len());
let mut from = Vec::with_capacity(fo.len() + fi.len());
for (i, f) in fo.iter().enumerate() {
let (face, change) = if i == *io { (holed.clone(), Change::Modified) } else { ((*f).clone(), Change::Kept) };
faces.push(face);
from.push(record::from_face(outer_operand, i, change));
}
for (i, f) in fi.iter().enumerate().filter(|(i, _)| i != ii) {
faces.push((*f).clone());
from.push(record::from_face(inner_operand, i, Change::Kept));
}
let solid = Solid::try_new(vec![faces.into()]).map_err(|e| fail(&e.to_string()))?;
let mut origins = record::edge_origins(&outer.solid, outer_operand);
origins.extend(record::edge_origins(&inner.solid, inner_operand));
let origins = crate::body::edge_order(solid.face_iter()).0.iter().map(|e| origins.get(&e.id()).cloned()).collect();
let counts = if outer_operand == 0 { record::counts(&[outer, inner]) } else { record::counts(&[inner, outer]) };
crate::boolean_record::heal_build(solid, size, &[], Some(from), origins, &counts)
}));
}
}
None
}
/// A cylindrical face: its index, a point on the axis, the unit axis and the radius.
#[derive(Clone, Copy, Debug)]
struct CylFace {
+36 -2
View File
@@ -156,6 +156,9 @@ fn contradicted(
(a_in_b && under_surface(ma, ia, ib, tol, false, None)) || (b_in_a && !a_in_b && under_surface(mb, ib, ia, tol, false, None))
}
/// Sampled overlap fraction below which two bodies share no material.
const NONE: f64 = 0.003;
/// Booleans that need no intersection: one body clear of the other, or inside it. These are
/// common with coincident curved faces (a hole drilled where one already is, a body joined
/// with itself), which the intersection can't handle. `counts`: the operands' faces and edges.
@@ -178,7 +181,6 @@ fn trivial(
}
let (ia, ib) = (ma.inside_index(), mb.inside_index());
let (b_in_a, a_in_b) = overlap(ma, mb, &ia, &ib)?;
const NONE: f64 = 0.003;
const ALL: f64 = 0.997;
let disjoint = b_in_a < NONE && a_in_b < NONE;
// The samples only say that nearly all of one body is clear of, or inside, the other. A
@@ -404,6 +406,19 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
let slack = 2e-3 * (va + vb) + 1e-9;
// Volume bounds, then membership against the operands (catches misclassified pieces), on
// one mesh of the result. Returns (plausible, volume).
// Shells that touch were never fused: coincident back-to-back faces leave the intersection
// nothing to cut, so it returns both operands side by side (#396). Volume and point
// sampling cannot see that; the shell grouping can. Only contact over an area counts
// (bodies tangent along a line stay two lumps), and operands already like that, saved
// before this was caught, stay joinable.
let unfused = |r: &Body| {
op == BoolOp::Union
&& r.shell_count() > 1
&& r.union_lumps().is_err()
&& a.union_lumps().is_ok()
&& b.union_lumps().is_ok()
&& crate::coplanar::faces_touch(a, b)
};
let check = |r: &Body| -> (bool, f64) {
let Ok(mr) = r.tessellate_with(tol_m, true) else { return (false, f64::NAN) };
let v = mr.measure().volume;
@@ -416,6 +431,9 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
if !bounds {
return (false, v);
}
if unfused(r) {
return (false, v);
}
let consistent = match &meshes {
(Some(ma), Some(mb)) => mismatch(ma, mb, &mr, op).is_none_or(|f| f < 0.004),
_ => true,
@@ -502,6 +520,18 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
Some(Ok(None)) | None => {}
}
}
// A face standing wholly inside a coincident face of the other body, back to back, and no
// other contact (#396): exact, by topology alone.
if op == BoolOp::Union
&& a.solid.boundaries().len() == 1
&& b.solid.boundaries().len() == 1
&& let Some(Ok(body)) = crate::coplanar::inset(a, b)
&& let (Some(ma), Some(mb)) = &meshes
&& overlap(ma, mb, &ma.inside_index(), &mb.inside_index()).is_some_and(|(b_in_a, a_in_b)| b_in_a < NONE && a_in_b < NONE)
&& check(&body).0
{
return Ok(Some(body));
}
// Bores on a common cylinder (a pad's bore around the hole it is joined over).
if op == BoolOp::Union && !BORES.with(|c| c.get()) && crate::coplanar::has_coincident_cylinders(a, b) {
BORES.with(|c| c.set(true));
@@ -548,7 +578,11 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
}
return Ok(Some(body));
}
last = format!("implausible result volume {v:.4}");
last = if unfused(&body) {
"the bodies touch along faces that could not be fused".into()
} else {
format!("implausible result volume {v:.4}")
};
}
Err(e) => last = e.to_string(),
},
+27
View File
@@ -2249,6 +2249,33 @@ fn shell_open_beside_a_round_as_large_as_the_wall_is_refused() {
assert!(shell(&f, &bottom, 2.0).unwrap().validity().is_empty());
}
#[test]
fn loft_joined_onto_a_box_top_is_one_shell() {
// #396: a loft from a Ø12 circle on the box top (z = 20) to a hexagon at z = 35. The union
// returned both shells side by side, touching on the disc, which an STL shows as two parts.
let bx = box_solid(Vec3::new(-20.0, -15.0, 0.0), Vec3::new(20.0, 15.0, 20.0)).unwrap();
let hex: Vec<Vec2> = (0..6).map(|k| Vec2::new(4.0 * (PI / 3.0 * k as f64).cos(), 4.0 * (PI / 3.0 * k as f64).sin())).collect();
let loft_at = |x: f64| {
let hex: Vec<Vec2> = hex.iter().map(|p| *p + Vec2::new(x, 0.0)).collect();
loft(&[(Plane::XY.offset(20.0), Loop2::circle(Vec2::new(x, 0.0), 6.0)), (Plane::XY.offset(35.0), Loop2::polygon(&hex))]).unwrap()
};
let l = loft_at(0.0);
let u = boolean(&bx, &l, BoolOp::Union).unwrap().unwrap();
assert_eq!((u.shell_count(), u.face_count()), (1, 6 + 8 - 1), "the loft's bottom disc goes");
assert!(u.validity().is_empty(), "{:?}", u.validity());
let (v, vl) = (measure(&u).unwrap().volume, measure(&l).unwrap().volume);
assert!(rel(v, 24000.0 + vl) < 1e-5, "{v} vs {}", 24000.0 + vl);
// Either order of the operands.
let w = boolean(&l, &bx, BoolOp::Union).unwrap().unwrap();
assert_eq!(w.shell_count(), 1);
assert!(rel(measure(&w).unwrap().volume, 24000.0 + vl) < 1e-5);
// Overhanging the box edge (x = 20) the disc is not inside the top face: whatever the join
// does, it never returns touching shells as one body.
if let Ok(Some(r)) = boolean(&bx, &loft_at(17.0), BoolOp::Union) {
assert!(r.shell_count() == 1 || r.union_lumps().is_ok(), "{} touching shells", r.shell_count());
}
}
/// A disc whose circular edge is rounded smaller than the wall (#319): moved in by the
/// thickness the round collapses to a sharp edge, where the floor meets the side along a
/// circle (two arcs with the same ends), not a straight line.