From 24cff445adbce3fa34e7d9af5199e28d5b852d6f Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Tue, 13 Oct 2026 12:04:41 +0200 Subject: [PATCH] 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. --- crates/engine/src/cmd/features_more_tests.rs | 53 ++++++++ crates/kernel/src/body.rs | 6 + crates/kernel/src/coplanar.rs | 123 +++++++++++++++++++ crates/kernel/src/ops.rs | 38 +++++- crates/kernel/src/tests.rs | 27 ++++ 5 files changed, 245 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 885ae1fd..0ee45bdf 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -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 = (0..positions.len()).collect(); + let root = |piece: &mut Vec, 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 = 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] diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 0acd96b1..cdcc3c3b 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -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> { + self.lumps_with_clearance(2.0 * (self.size() * 1e-4).max(1e-6)) + } + fn lumps_with_clearance(&self, clearance: f64) -> Result> { if self.mesh.is_some() || self.solid.boundaries().len() < 2 { return Ok(vec![self.clone()]); diff --git a/crates/kernel/src/coplanar.rs b/crates/kernel/src/coplanar.rs index f46e4ec4..7fb944e4 100644 --- a/crates/kernel/src/coplanar.rs +++ b/crates/kernel/src/coplanar.rs @@ -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> { 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> { + 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> { + 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> { + 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::>>()) 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::>()) + }; + 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 { diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 5f15de10..2f6fbf9d 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -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> { 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> { 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> { 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> { } 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(), }, diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index ec988484..a8988099 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -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 = (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 = 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.