From d1b96100d33088a5063b0bd6bdc557f1dd4c2ebe Mon Sep 17 00:00:00 2001
From: Mateusz Sych <151755914+SychPL@users.noreply.github.com>
Date: Sat, 17 Oct 2026 13:21:06 +0200
Subject: [PATCH] Split Body: a plane lying on a face of the body splits it
exactly (#1510) (#1897)
Thanks @SychPL.
---
crates/kernel/src/issue_1510_tests.rs | 79 ++++++++++++++++++++++++++
crates/kernel/src/lib.rs | 2 +
crates/kernel/src/ops.rs | 82 ++++++++++++++++++++++++++-
3 files changed, 162 insertions(+), 1 deletion(-)
create mode 100644 crates/kernel/src/issue_1510_tests.rs
diff --git a/crates/kernel/src/issue_1510_tests.rs b/crates/kernel/src/issue_1510_tests.rs
new file mode 100644
index 00000000..b942ebe1
--- /dev/null
+++ b/crates/kernel/src/issue_1510_tests.rs
@@ -0,0 +1,79 @@
+//! #1510: a body split by a plane that a face of the body lies in: the parts meet in the plane
+//! and together are the body.
+
+use solidcraft_geom::{Plane, Vec3};
+
+use crate::{Body, BoolOp, build::box_solid, build::cylinder, ops::boolean, ops::split_by_plane};
+
+fn cut(a: &Body, t: crate::Result
) -> Body {
+ boolean(a, &t.unwrap(), BoolOp::Cut).unwrap().unwrap()
+}
+
+/// A 2.5 mm plate with a through hole of radius 2.5 and a groove 4 mm wide and 1.5 deep from
+/// the bottom, running from the hole's centre to the edge: the groove's ceiling lies at z = 1.5
+/// and its boundary runs into the hole.
+fn plate(width: f64) -> Body {
+ let p = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 20.0, 2.5)).unwrap();
+ let p = cut(&p, cylinder(Vec3::new(20.0, 10.0, -1.0), Vec3::Z, 2.5, 5.0));
+ cut(&p, box_solid(Vec3::new(20.0, 10.0 - width / 2.0, -1.0), Vec3::new(41.0, 10.0 + width / 2.0, 1.5)))
+}
+
+#[test]
+fn a_split_in_the_plane_of_a_face_keeps_all_of_the_body() {
+ for width in [4.0, 5.0] {
+ let b = plate(width);
+ let v = b.volume_at(1e-3).unwrap();
+ let plane = Plane::from_normal(Vec3::new(0.0, 0.0, 1.5), Vec3::Z).unwrap();
+ let (parts, d) = crate::capture_boolean_diagnostics(|| split_by_plane(&b, &plane));
+ let parts = parts.unwrap();
+ assert_eq!(parts.len(), 2, "{width}");
+ let vs: Vec = parts.iter().map(|p| p.volume_at(1e-3).unwrap()).collect();
+ assert!((vs.iter().sum::() - v).abs() < 1e-6 * v, "{width}: {vs:?} against {v}; {:?}", d.summary());
+ // Above: the plate from 1.5 to 2.5 less the hole; below: less the hole and the groove.
+ let (r, h) = (2.5f64, width / 2.0);
+ let hole = std::f64::consts::PI * r * r;
+ // The strip 20 <= x <= 40, |y - 10| <= h, less the part of it in the hole.
+ let groove = width * 20.0 - (h * (r * r - h * h).sqrt() + r * r * (h / r).asin());
+ let want = [800.0 - hole, 1.5 * (800.0 - hole - groove)];
+ for (got, want) in vs.iter().zip(want) {
+ assert!((got - want).abs() < 0.05, "{width}: {vs:?} against {want}; {:?}", d.summary());
+ }
+ // Both parts end in the plane, not off it.
+ let z: Vec<(f64, f64)> = parts.iter().map(|p| crate::measure(p).map(|m| (m.bbox.min.z, m.bbox.max.z)).unwrap()).collect();
+ assert!((z[0].0 - 1.5).abs() < 1e-6 && (z[1].1 - 1.5).abs() < 1e-6, "{width}: {z:?}; {:?}", d.summary());
+ }
+}
+
+/// A slab whose bottom rises 0.03 mm over its 700 mm length, its plane through the origin: its
+/// normal is within 1e-9 of the split plane's, but it doesn't lie in the plane, so it is not
+/// moved, and the part on the upper side is the whole slab.
+#[test]
+fn a_face_only_nearly_in_the_plane_is_not_moved() {
+ use truck_modeling as mt;
+ let v = |x: f64, y: f64, z: f64| mt::builder::vertex(crate::body::p3(Vec3::new(x, y, z)));
+ let p = [v(0.0, 0.0, 0.0), v(0.0, 100.0, 0.0), v(700.0, 100.0, 0.03), v(700.0, 0.0, 0.03)];
+ let wire: mt::Wire = (0..4).map(|i| mt::builder::line(&p[i], &p[(i + 1) % 4])).collect();
+ let surface = mt::Plane::new(crate::body::p3(Vec3::ZERO), crate::body::p3(Vec3::Y), crate::body::p3(Vec3::new(700.0, 0.0, 0.03)));
+ let bottom = mt::Face::new(vec![wire], mt::Surface::Plane(surface));
+ let b = Body::new(mt::builder::tsweep(&bottom, mt::Vector3::new(0.0, 0.0, 10.0))).unwrap();
+ let plane = Plane::from_normal(Vec3::ZERO, Vec3::Z).unwrap();
+ assert!(crate::ops::off_plane(&b, &plane, true).is_none());
+ let v0 = b.volume_at(1e-2).unwrap();
+ let up = crate::ops::half_space(&b, &plane, true).unwrap().0.unwrap();
+ let v1 = up.volume_at(1e-2).unwrap();
+ assert!((v1 - v0).abs() < 1e-3 * v0, "{v1} against {v0}");
+}
+
+/// A box whose top face is 3e-5 above the plane, within the boolean's tolerance but off it: the
+/// face bounds a real sliver, and it is not moved.
+#[test]
+fn a_face_close_to_the_plane_but_off_it_is_not_moved() {
+ let b = box_solid(Vec3::new(0.0, 0.0, -10.0), Vec3::new(700.0, 100.0, 3e-5)).unwrap();
+ let plane = Plane::from_normal(Vec3::ZERO, Vec3::Z).unwrap();
+ assert!(crate::ops::off_plane(&b, &plane, true).is_none());
+ assert!(crate::ops::off_plane(&b, &plane, false).is_none());
+ // A face in the plane is moved.
+ let plate = plate(4.0);
+ let at = Plane::from_normal(Vec3::new(0.0, 0.0, 1.5), Vec3::Z).unwrap();
+ assert!(crate::ops::off_plane(&plate, &at, true).is_some() && crate::ops::off_plane(&plate, &at, false).is_some());
+}
diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs
index 69c79cdf..8599daae 100644
--- a/crates/kernel/src/lib.rs
+++ b/crates/kernel/src/lib.rs
@@ -57,6 +57,8 @@ mod issue_1038_tests;
#[cfg(test)]
mod issue_1080_tests;
#[cfg(test)]
+mod issue_1510_tests;
+#[cfg(test)]
mod issue_324_tests;
#[cfg(test)]
mod issue_616_tests;
diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs
index a8b57a48..78908bdf 100644
--- a/crates/kernel/src/ops.rs
+++ b/crates/kernel/src/ops.rs
@@ -1528,7 +1528,87 @@ pub fn split_by_plane(body: &Body, plane: &solidcraft_geom::Plane) -> Result Result<(Option, Body)> {
// The box's own construction roles describe no face of the design: the cut face has none.
let tool = half_box(body, plane, positive)?.with_face_sources(None);
- Ok((boolean(body, &tool, BoolOp::Intersect)?, tool))
+ let (part, report) = capture_boolean_diagnostics(|| boolean(body, &tool, BoolOp::Intersect));
+ // Faces of the body lying in the plane coincide with the box's cap, which the boolean may
+ // only take by nudging the box, by a share of its own size: the cut face then moves off the
+ // plane, a different way for each part, and the parts lose the slab between (#1510). Moved
+ // off the plane to the side the box leaves out, those faces change nothing of the part, and
+ // the cap meets no face. Asked only when the boolean failed or nudged the box: a part made
+ // without, its faces' names included, stays as it is.
+ let nudged = report.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge);
+ if (part.is_err() || nudged)
+ && let Some(moved) = off_plane(body, plane, positive)
+ {
+ let (exact, moved_report) = capture_boolean_diagnostics(|| boolean(&moved, &tool, BoolOp::Intersect));
+ if let Ok(exact) = exact {
+ merge(moved_report);
+ // The part's history over [body, box], through the moved body (its faces are the body's).
+ let hb = recorded::placed(recorded::through(&moved, 0, recorded::kept(body, 0, 1).as_ref()), 0, 2);
+ let ht = recorded::placed(recorded::kept(&tool, 0, 1), 1, 2);
+ let counts = record::counts(&[body, &tool]);
+ let exact = exact.map(|r| {
+ let h = recorded::of_pair(&r, &hb, &ht);
+ r.with_checked_history(h, &counts)
+ });
+ return Ok((exact, tool));
+ }
+ }
+ merge(report);
+ Ok((part?, tool))
+}
+
+/// `body` with its planar faces lying in `plane` moved off it, a thousandth of the body's size,
+/// to the side a split's part on the `positive` side leaves out. `None` when no face lies there
+/// or moving them doesn't give a sound body.
+pub(crate) fn off_plane(body: &Body, plane: &solidcraft_geom::Plane, positive: bool) -> Option {
+ let m = plane.normal().normalized()?;
+ let keep = if positive { m } else { -m };
+ let size = body.size();
+ let tol = (size * 1e-7).max(1e-9);
+ // Lying in the plane means in it, not within the boolean's tolerance of it: a face that close
+ // but off it bounds a real sliver, which moving the face would take or add.
+ let exact = (size * 1e-9).max(1e-12);
+ let delta = size * 1e-3;
+ let mut out: Option = None;
+ for n in [keep, -keep] {
+ let d = n.dot(plane.origin);
+ // The faces `push_faces` takes for this plane (by their surface), which must all lie in
+ // it: one only nearly parallel, its origin in the plane, would be moved where it isn't.
+ let taken: Vec<&mt::Face> = body
+ .solid
+ .face_iter()
+ .filter(|f| match f.oriented_surface() {
+ mt::Surface::Plane(p) => {
+ let q = p.normal();
+ Vec3::new(q.x, q.y, q.z).normalized().is_some_and(|q| q.dot(n) > 1.0 - 1e-9)
+ && (n.dot(from_p3(p.origin())) - d).abs() < tol * 10.0
+ }
+ _ => false,
+ })
+ .collect();
+ if taken.is_empty() {
+ continue;
+ }
+ // Its vertices and points along its edges (a disc has one vertex).
+ let lies = |f: &mt::Face| {
+ use mt::{BoundedCurve, ParametricCurve};
+ let on = |p: mt::Point3| (n.dot(from_p3(p)) - d).abs() < exact;
+ f.vertex_iter().all(|v| on(v.point()))
+ && f.edge_iter().all(|e| {
+ let c = e.curve();
+ let (t0, t1) = c.range_tuple();
+ [0.25, 0.5, 0.75].iter().all(|k| on(c.subs(t0 + (t1 - t0) * k)))
+ })
+ };
+ if !taken.iter().all(|f| lies(f)) {
+ return None;
+ }
+ // Along -keep: inwards for a face facing the kept side, outwards for one facing away.
+ let shift = if n.dot(keep) > 0.0 { -delta } else { delta };
+ let cur = out.as_ref().unwrap_or(body);
+ out = Some(crate::coplanar::push_faces(cur, n, d, shift).ok().filter(|b| b.validity().is_empty())?);
+ }
+ out
}
/// A box covering everything of `body` on one side of a plane, its start cap in the plane.