Kernel: split a body by a plane or another face's surface

split_body(body, tool) divides every face where the tool meets it and
closes each side with the piece of the tool surface inside the body.
- Patches have outer loops and holes.
- Bands go round a cylinder or another surface of revolution.
- Each side falls apart into its connected parts.
- A body the tool misses comes back whole.

Faces that are B-spline sheets but really planes or circular cylinders
(extruded faces) split as the whole plane or cylinder: the tool is
extended, and the field is exact and cheap.

Tests (volumes): a box split by a plane (2400/3600); a holed plate split
through its hole into two equal halves; a box split by a cylinder's side
into the core (pi*16*30) and the rest; a plane that misses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 17:31:57 +00:00
1 parent 69d7bcfe7d
commit 34be218639
3 files changed
+364 -6

No files matched your search

+1 -1
View File
@@ -52,7 +52,7 @@ pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
pub use polybool::planar_boolean;
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
pub use splitface::{SplitTool, split_faces};
pub use splitface::{SplitTool, split_body, split_faces};
pub use step::step_export;
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate};
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
+362 -4
View File
@@ -30,9 +30,23 @@ pub enum SplitTool<'a> {
/// Which side of the tool a point is on (zero on the tool).
enum Field {
Plane { o: Vec3, n: Vec3 },
Plane {
o: Vec3,
n: Vec3,
},
Surface(mt::Surface),
Curve { plane: Plane, pts: Vec<Vec2> },
/// A circular cylinder (radius `r` about the line through `o` along unit `axis`); `out` is
/// +1 when the tool's normal points away from the axis.
Cylinder {
o: Vec3,
axis: Vec3,
r: f64,
out: f64,
},
Curve {
plane: Plane,
pts: Vec<Vec2>,
},
}
impl Field {
@@ -47,6 +61,11 @@ impl Field {
}
None => f64::NAN,
},
Field::Cylinder { o, axis, r, out } => {
let v = p - *o;
let radial = v - *axis * v.dot(*axis);
*out * (radial.len() - *r)
}
Field::Curve { plane, pts } => {
let q = plane.to_local(p);
let q = Vec2::new(q.x, q.y);
@@ -214,7 +233,11 @@ fn trace(face: &mt::Face, g: &Field, size: f64) -> Result<Vec<Chain>> {
hi = mid;
}
}
let uv = f(0.5 * (lo + hi)).1;
let (val, uv) = f(0.5 * (lo + hi));
// A side whose ends were on the same side after all (a corner exactly on the tool).
if !(val.abs() <= size * 1e-7) {
return None;
}
Some((surface.subs(uv.0, uv.1), uv))
};
// Walk the links into chains.
@@ -629,13 +652,67 @@ fn split_one(faces: &mut Vec<mt::Face>, fi: usize, g: &Field, size: f64, new_edg
Ok((made, count))
}
/// The plane or circular cylinder a (B-spline) surface is, as a field with the surface's
/// normal; other surfaces as they are.
fn surface_field(s: &mt::Surface) -> Field {
let (Some((u0, u1)), Some((v0, v1))) = s.try_range_tuple() else { return Field::Surface(s.clone()) };
if !matches!(s, mt::Surface::Plane(_) | mt::Surface::BSplineSurface(_) | mt::Surface::NurbsSurface(_)) {
return Field::Surface(s.clone());
}
let at = |a: f64, b: f64| from_p3(s.subs(u0 + (u1 - u0) * a, v0 + (v1 - v0) * b));
let nrm = |a: f64, b: f64| {
let n = s.normal(u0 + (u1 - u0) * a, v0 + (v1 - v0) * b);
Vec3::new(n.x, n.y, n.z)
};
let grid: Vec<(f64, f64)> = (0..=6).flat_map(|i| (0..=6).map(move |j| (i as f64 / 6.0, j as f64 / 6.0))).collect();
let size = grid.iter().map(|(a, b)| at(*a, *b).dist(at(0.0, 0.0))).fold(1e-9, f64::max);
let tol = size * 1e-7;
// A plane: one normal, every point on it.
let n0 = nrm(0.5, 0.5);
let o = at(0.5, 0.5);
if grid.iter().all(|(a, b)| (at(*a, *b) - o).dot(n0).abs() <= tol) {
return Field::Plane { o, n: n0 };
}
// A cylinder: straight along one parameter, a circle along the other.
for along_v in [true, false] {
let pt = |t: f64, w: f64| if along_v { at(t, w) } else { at(w, t) };
let d = pt(0.0, 1.0) - pt(0.0, 0.0);
let Some(axis) = d.normalized() else { continue };
let straight = (0..=6).all(|k| {
let t = k as f64 / 6.0;
(0..=4).all(|m| (pt(t, m as f64 / 4.0) - pt(t, 0.0) - d * (m as f64 / 4.0)).len() <= tol)
});
if !straight {
continue;
}
let prof: Vec<Vec3> = (0..=8).map(|k| pt(k as f64 / 8.0, 0.0)).collect();
let (a, b, c) = (prof[0], prof[3], prof[6]);
let (ab, ac) = (b - a, c - a);
let nn = ab.cross(ac);
let n2 = nn.dot(nn);
if !(n2 > 1e-24) {
continue;
}
let centre = a + (nn.cross(ab) * ac.dot(ac) + ac.cross(nn) * ab.dot(ab)) * (0.5 / n2);
let r = (a - centre).len();
if prof.iter().all(|p| ((*p - centre).len() - r).abs() <= tol) && nn.normalized().is_some_and(|nu| nu.cross(axis).len() < 1e-6) {
let mid = pt(0.5, 0.5);
let v = mid - centre;
let radial = v - axis * v.dot(axis);
let out = if nrm(0.5, 0.5).dot(radial) >= 0.0 { 1.0 } else { -1.0 };
return Field::Cylinder { o: centre, axis, r, out };
}
}
Field::Surface(s.clone())
}
fn fields(tool: &SplitTool) -> Result<Vec<Field>> {
Ok(match tool {
SplitTool::Plane(p) => vec![Field::Plane { o: p.origin, n: p.normal() }],
SplitTool::Face { body: other, face } => {
other.require_brep("a split tool")?;
let f = other.solid.face_iter().nth(*face).ok_or_else(|| KernelError::Invalid(format!("the tool body has no face {face}")))?;
vec![Field::Surface(f.oriented_surface())]
vec![surface_field(&f.oriented_surface())]
}
SplitTool::Curves { plane, curves } => {
if curves.iter().any(|c| c.len() < 2 || c.iter().any(|p| !(p.x.is_finite() && p.y.is_finite()))) {
@@ -700,6 +777,256 @@ pub fn split_faces(body: &Body, faces: &[usize], tool: &SplitTool) -> Result<Bod
})
}
/// A point well inside a face (a mesh triangle's centre where the field is largest) and the
/// field there.
fn side_of(face: &mt::Face, g: &Field, size: f64) -> Result<f64> {
let shell: mt::Shell = vec![face.clone()].into();
let meshed = shell.robust_triangulation((size * 2e-3).max(1e-4));
let pm = meshed.face_iter().next().and_then(|f| f.surface()).ok_or_else(|| KernelError::Failed("a face could not be meshed".into()))?;
let pos = pm.positions();
let mut best: f64 = 0.0;
for t in pm.faces().triangle_iter() {
let c = t.iter().filter_map(|v| pos.get(v.pos)).fold(Vec3::ZERO, |a, p| a + from_p3(*p) / 3.0);
let x = g.eval(c);
if x.is_finite() && x.abs() > best.abs() {
best = x;
}
}
Ok(best)
}
/// Loops from edges (each used once), chained end to start; edges are turned round as needed.
fn chain_loops(edges: &[mt::Edge]) -> Result<Vec<Vec<mt::Edge>>> {
let mut pool: Vec<mt::Edge> = edges.to_vec();
let mut out = Vec::new();
while let Some(first) = pool.pop() {
let start = first.front().id();
let mut at = first.back().clone();
let mut lp = vec![first];
while at.id() != start {
let Some(k) = pool.iter().position(|e| e.front().id() == at.id() || e.back().id() == at.id()) else {
return Err(KernelError::Failed("the section through the body does not close".into()));
};
let e = pool.remove(k);
let e = if e.front().id() == at.id() { e } else { e.inverse() };
at = e.back().clone();
lp.push(e);
if lp.len() > 1_000_000 {
return Err(KernelError::Failed("section loop too long".into()));
}
}
out.push(lp);
}
Ok(out)
}
/// Faces grouped by shared edges (in face order).
fn components(faces: Vec<mt::Face>) -> Vec<Vec<mt::Face>> {
let n = faces.len();
let mut by_edge: HashMap<mt::EdgeID, Vec<usize>> = HashMap::new();
for (i, f) in faces.iter().enumerate() {
for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) {
by_edge.entry(e.id()).or_default().push(i);
}
}
let mut comp = vec![usize::MAX; n];
let mut count = 0;
for s in 0..n {
if comp[s] != usize::MAX {
continue;
}
comp[s] = count;
let mut stack = vec![s];
while let Some(i) = stack.pop() {
let Some(f) = faces.get(i) else { continue };
for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) {
for &j in by_edge.get(&e.id()).into_iter().flatten() {
if comp[j] == usize::MAX {
comp[j] = count;
stack.push(j);
}
}
}
}
count += 1;
}
let mut out: Vec<Vec<mt::Face>> = vec![Vec::new(); count];
for (f, c) in faces.into_iter().zip(comp) {
if let Some(v) = out.get_mut(c) {
v.push(f);
}
}
out
}
/// Split a body into the parts on either side of a tool: a plane, or another face's surface
/// (extended). Faces are divided where the tool meets them and every part is closed with the
/// piece of the tool's surface inside the body; parts come in face order, those on the tool's
/// back first. A body the tool misses comes back whole.
pub fn split_body(body: &Body, tool: &SplitTool) -> Result<Vec<Body>> {
body.require_brep("split body")?;
if body.solid.boundaries().len() != 1 {
return Err(KernelError::Invalid("not supported yet: splitting a body with voids".into()));
}
let size = body.size();
let fields = fields(tool)?;
let [g] = fields.as_slice() else { return Err(KernelError::Invalid("a body is split by a plane or a face".into())) };
// The tool surface, with the field's normal.
// The tool's whole surface for the section (a sheet that is part of a plane or cylinder
// is extended to all of it), with the field's normal.
let surface = match g {
Field::Plane { o, n } => {
let x = (if n.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).cross(*n).normalized().unwrap_or(Vec3::X);
let y = n.cross(x);
let o = p3(*o);
mt::Surface::Plane(mt::Plane::new(o, o + crate::body::v3(x), o + crate::body::v3(y)))
}
Field::Cylinder { o, axis, r, out } => {
// A line along the axis at radius r, long enough for the body, revolved.
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for v in body.solid.vertex_iter() {
let t = (from_p3(v.point()) - *o).dot(*axis);
lo = lo.min(t);
hi = hi.max(t);
}
let (lo, hi) = (lo - size, hi + size);
let x = (if axis.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).cross(*axis).normalized().unwrap_or(Vec3::X);
let (a, b) = (*o + *axis * lo + x * *r, *o + *axis * hi + x * *r);
let line = mt::Curve::Line(mt::Line(p3(a), p3(b)));
crate::step_in::geom::revolved_oriented(line, p3(*o), crate::body::v3(*axis), 0.5, crate::body::v3(x * *out))
}
Field::Surface(s) => s.clone(),
Field::Curve { .. } => return Err(KernelError::Invalid("a body is split by a plane or a face".into())),
};
let total = body.solid.face_iter().count();
let all: Vec<usize> = (0..total).collect();
guard("split body", || {
let (shells, new_edges, made) = imprint(body, &all, &fields, size)?;
if made == 0 || new_edges.is_empty() {
return Ok(vec![body.clone()]);
}
let faces: Vec<mt::Face> = shells.into_iter().flatten().collect();
// The section: loops of the new edges on the tool surface, outer loops counter-clockwise
// about its normal and holes inside them.
let loops = chain_loops(&new_edges)?;
let polys: Vec<Vec<(f64, f64)>> = loops.iter().map(|l| loop_uv(&surface, &l.clone().into())).collect();
let signed = |p: &[(f64, f64)]| {
(0..p.len())
.map(|i| {
let (a, b) = (p[i], p[(i + 1) % p.len()]);
a.0 * b.1 - b.0 * a.1
})
.sum::<f64>()
};
let depth: Vec<usize> = (0..loops.len())
.map(|i| {
let q = polys.get(i).and_then(|p| p.first()).copied().unwrap_or((0.0, 0.0));
(0..loops.len()).filter(|&j| j != i && polys.get(j).is_some_and(|p| inside(p, q))).count()
})
.collect();
let wire = |i: usize, ccw: bool| -> mt::Wire {
let w: mt::Wire = loops.get(i).cloned().unwrap_or_default().into();
let is_ccw = polys.get(i).is_some_and(|p| signed(p) > 0.0);
if is_ccw == ccw { w } else { w.inverse() }
};
let mut section = Vec::new();
// On a surface of revolution, loops that run once round it (a cylinder through a box:
// its circles at the top and bottom) bound bands: taken in order along the profile,
// each pair is one face, the lower loop running back round and the upper forward.
let mut wrapping = vec![false; loops.len()];
if let mt::Surface::RevolutedCurve(rc) = &surface {
let (o, axis) = (rc.entity().origin(), rc.entity().axis().normalize());
let x0 = if axis.x.abs() < 0.9 { mt::Vector3::unit_x() } else { mt::Vector3::unit_y() };
let ex = (x0 - axis * x0.dot(axis)).normalize();
let ey = axis.cross(ex);
let tau = std::f64::consts::TAU;
let samples = |w: &mt::Wire| -> Vec<mt::Point3> {
w.edge_iter()
.flat_map(|e| {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
(0..32).map(move |k| c.subs(t0 + (t1 - t0) * k as f64 / 32.0)).collect::<Vec<_>>()
})
.collect()
};
let mut bands: Vec<(f64, usize)> = Vec::new();
for (i, l) in loops.iter().enumerate() {
let pts = samples(&l.clone().into());
let ang = |p: &mt::Point3| (p - o).dot(ey).atan2((p - o).dot(ex));
let turn: f64 = (0..pts.len())
.map(|k| {
let d = ang(&pts[(k + 1) % pts.len()]) - ang(&pts[k]);
d - tau * (d / tau).round()
})
.sum();
if (turn.abs() - tau).abs() < 0.1 {
let along = pts.iter().map(|p| (p - o).dot(axis)).sum::<f64>() / pts.len().max(1) as f64;
bands.push((along, i));
if let Some(w) = wrapping.get_mut(i) {
*w = true;
}
}
}
bands.sort_by(|a, b| a.0.total_cmp(&b.0));
// A loop runs so that the band (towards `toward` along the axis) is on its left about
// the surface normal.
let oriented = |i: usize, toward: f64| -> mt::Wire {
let w: mt::Wire = loops.get(i).cloned().unwrap_or_default().into();
let pts = samples(&w);
let (Some(p), Some(q)) = (pts.first(), pts.get(1)) else { return w };
let n = surface.search_nearest_parameter(*p, None, 50).map(|(u, v)| surface.normal(u, v)).unwrap_or(axis);
let left = n.cross(q - p);
if left.dot(axis) * toward >= 0.0 { w } else { w.inverse() }
};
for pair in bands.chunks(2) {
let [(_, lo), (_, hi)] = pair else { return Err(KernelError::Failed("an unmatched band edge in the section".into())) };
section.push(mt::Face::new_unchecked(vec![oriented(*lo, 1.0), oriented(*hi, -1.0)], surface.clone()));
}
}
for (i, d) in depth.iter().enumerate() {
if d % 2 != 0 || wrapping.get(i).copied().unwrap_or(false) {
continue;
}
let mut ws = vec![wire(i, true)];
for (j, dj) in depth.iter().enumerate() {
let q = polys.get(j).and_then(|p| p.first()).copied().unwrap_or((0.0, 0.0));
if *dj == d + 1 && polys.get(i).is_some_and(|p| inside(p, q)) {
ws.push(wire(j, false));
}
}
section.push(mt::Face::new_unchecked(ws, surface.clone()));
}
// Each face goes to the side its inside is on.
let (mut back, mut front) = (Vec::new(), Vec::new());
for f in faces {
let x = side_of(&f, g, size)?;
if x.abs() <= size * 1e-9 {
return Err(KernelError::Invalid("not supported yet: the tool lies along a face of the body".into()));
}
if x < 0.0 {
back.push(f);
} else {
front.push(f);
}
}
// The section closes the back side facing the tool's normal, the front side facing away.
back.extend(section.iter().cloned());
front.extend(section.iter().map(|f| f.inverse()));
let mut parts = Vec::new();
for side in [back, front] {
for comp in components(side) {
let shell = mt::Shell::from(comp);
if shell.shell_condition() != mt::ShellCondition::Closed {
return Err(KernelError::Failed("a part of the split does not close".into()));
}
parts.push(Body::new(mt::Solid::new_unchecked(vec![shell]))?.with_color(body.color));
}
}
Ok(parts)
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -783,6 +1110,37 @@ mod tests {
assert!(tops.iter().all(|a| rel(*a, half) < 1e-3), "{tops:?}");
}
fn volumes(parts: &[Body]) -> Vec<f64> {
let mut v: Vec<f64> = parts.iter().map(|b| measure(b).unwrap().volume).collect();
v.sort_by(f64::total_cmp);
v
}
#[test]
fn split_body_by_plane_and_by_a_face() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let pl = Plane::new(Vec3::new(4.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
let v = volumes(&split_body(&b, &SplitTool::Plane(pl)).unwrap());
assert!(v.len() == 2 && rel(v[0], 2400.0) < 1e-9 && rel(v[1], 3600.0) < 1e-9, "{v:?}");
// A plate with a hole split through the hole: two equal halves.
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let pin = cylinder(Vec3::new(20.0, 15.0, -1.0), Vec3::Z, 5.0, 12.0).unwrap();
let holed = crate::boolean(&plate, &pin, crate::BoolOp::Cut).unwrap().unwrap();
let pl = Plane::new(Vec3::new(20.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
let v = volumes(&split_body(&holed, &SplitTool::Plane(pl)).unwrap());
let half = (12000.0 - std::f64::consts::PI * 250.0) / 2.0;
assert!(v.len() == 2 && rel(v[0], half) < 1e-4 && rel(v[1], half) < 1e-4, "{v:?}");
// A box split by a cylinder's side: the core and the rest.
let tool = cylinder(Vec3::new(5.0, 10.0, -5.0), Vec3::Z, 4.0, 40.0).unwrap();
let side = tool.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap();
let v = volumes(&split_body(&b, &SplitTool::Face { body: &tool, face: side }).unwrap());
let core = std::f64::consts::PI * 16.0 * 30.0;
assert!(v.len() == 2 && rel(v[0], core) < 1e-3 && rel(v[1], 6000.0 - core) < 1e-3, "{v:?}");
// A plane that misses gives the body back.
let far = Plane::new(Vec3::new(50.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
assert_eq!(split_body(&b, &SplitTool::Plane(far)).unwrap().len(), 1);
}
#[test]
fn a_tool_that_misses_is_an_error() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
+1 -1
View File
@@ -684,7 +684,7 @@ fn revolved(curve: mt::Curve, o: mt::Point3, axis: mt::Vector3) -> mt::Surface {
/// A surface of revolution whose normal at the profile point `(u, 0)` points along `expected`.
/// The rotation is reversed rather than the surface inverted: truck's inverted `Processor`
/// swaps the parameters but not the search hints, which breaks meshing.
fn revolved_oriented(curve: mt::Curve, o: mt::Point3, axis: mt::Vector3, u: f64, expected: mt::Vector3) -> mt::Surface {
pub(crate) fn revolved_oriented(curve: mt::Curve, o: mt::Point3, axis: mt::Vector3, u: f64, expected: mt::Vector3) -> mt::Surface {
let s = revolved(curve.clone(), o, axis);
if s.normal(u, 0.0).dot(expected) >= 0.0 {
return s;