Kernel: split faces by a plane, another face's surface or sketch curves

split_faces(body, faces, tool) divides the chosen faces where the tool
meets them and leaves the solid unchanged. It handles:
- lines across a face;
- closed lines inside a face (a disc inside a ring);
- lines that run into a hole (the face is divided around it).
Edges the line ends on are cut in every face that uses them. The tool's
zero set is traced on a finely divided triangulation of the face, each
point is placed exactly on the surface by bisection in its parameters,
the ends are placed exactly on the boundary edges, and a cubic is fitted
through the points (a line when they are straight).

Tests: a box top split by a plane (areas 80/120, later booleans still
work); a cylinder side split by a sketch line; a cylinder top split in
ring and disc by a smaller cylinder's face; a plate top split through
its hole into two equal halves; a tool that misses is an error. Volume
and area are unchanged in every case.

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 0670de3f57
commit f8a4ffbc67
2 files changed
+782

No files matched your search

+2
View File
@@ -32,6 +32,7 @@ mod polybool;
mod polyhedron;
mod prism;
mod sew;
mod splitface;
mod step;
mod step_in;
mod step_out;
@@ -51,6 +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 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};
+780
View File
@@ -0,0 +1,780 @@
//! Split Face: divide chosen faces of a body along where a tool meets them (a plane, another
//! face's surface, or sketch curves swept along the sketch normal), leaving the solid itself
//! unchanged. Edges the split line ends on are cut in every face that uses them.
//!
//! The tool is a signed field (which side of the tool a point is on). Its zero set on a face is
//! traced on the face's triangulation, every crossing is placed exactly on the surface by
//! bisection in the face's parameters, and the line ends exactly on the boundary edges.
use std::collections::HashMap;
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
use solvecraft_geom::{Plane, Vec2, Vec3};
use truck_meshalgo::tessellation::RobustMeshableShape;
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
use crate::{KernelError, Result, guard};
/// What splits the faces.
#[derive(Clone, Debug)]
pub enum SplitTool<'a> {
/// A plane (unbounded).
Plane(Plane),
/// The surface of another body's face (`face` in that body's face order), extended.
Face { body: &'a Body, face: usize },
/// Sketch curves (2D polylines in `plane`) swept along the plane's normal; each curve
/// splits on its own, its end segments extended.
Curves { plane: Plane, curves: Vec<Vec<Vec2>> },
}
/// Which side of the tool a point is on (zero on the tool).
enum Field {
Plane { o: Vec3, n: Vec3 },
Surface(mt::Surface),
Curve { plane: Plane, pts: Vec<Vec2> },
}
impl Field {
fn eval(&self, p: Vec3) -> f64 {
match self {
Field::Plane { o, n } => (p - *o).dot(*n),
Field::Surface(s) => match s.search_nearest_parameter(p3(p), None, 50) {
Some((u, v)) => {
let q = from_p3(s.subs(u, v));
let n = s.normal(u, v);
(p - q).dot(Vec3::new(n.x, n.y, n.z))
}
None => f64::NAN,
},
Field::Curve { plane, pts } => {
let q = plane.to_local(p);
let q = Vec2::new(q.x, q.y);
// Signed distance to the polyline, its end segments extended as lines.
let n = pts.len();
let mut best = (f64::INFINITY, 0.0);
for i in 0..n.saturating_sub(1) {
let (Some(&a), Some(&b)) = (pts.get(i), pts.get(i + 1)) else { continue };
let d = b - a;
let l2 = d.dot(d);
if !(l2 > 0.0) {
continue;
}
let mut t = (q - a).dot(d) / l2;
if i > 0 {
t = t.max(0.0);
}
if i + 2 < n {
t = t.min(1.0);
}
let c = a + d * t;
let dist = (q - c).len();
if dist < best.0 {
let side = d.x * (q.y - a.y) - d.y * (q.x - a.x);
best = (dist, if side >= 0.0 { dist } else { -dist });
}
}
best.1
}
}
}
}
/// A crossing of the split line with a face boundary edge.
#[derive(Clone)]
struct Hit {
edge: mt::Edge,
t: f64,
p: mt::Point3,
}
/// One traced split line on a face: points (exact) with their surface parameters, and whether
/// it closes on itself.
struct Chain {
pts: Vec<(mt::Point3, (f64, f64))>,
closed: bool,
}
/// Mesh tolerance for tracing a face.
fn trace_tol(face: &mt::Face, size: f64) -> f64 {
let _ = face;
(size * 2e-3).max(1e-4)
}
/// Trace the zero set of `g` over a face: chains of exact points.
fn trace(face: &mt::Face, g: &Field, size: f64) -> Result<Vec<Chain>> {
let tol = trace_tol(face, size);
let shell: mt::Shell = vec![face.clone()].into();
let meshed = shell.robust_triangulation(tol);
let Some(mf) = meshed.face_iter().next() else { return Ok(Vec::new()) };
let Some(pm) = mf.surface() else { return Err(KernelError::Failed("the face could not be meshed".into())) };
let surface = face.surface();
let (pos, uvs) = (pm.positions(), pm.uv_coords());
// The face's triangles subdivided in parameter space, finely enough that a small split line
// (a hole's circle on a big face) crosses some triangle sides. Corners shared between
// triangles are found by their (rounded) parameters.
let step = size / 100.0;
let mut ids: HashMap<(i64, i64), usize> = HashMap::new();
let mut corner_uv: Vec<(f64, f64)> = Vec::new();
let (mut umin, mut umax, mut vmin, mut vmax) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
for uv in uvs {
umin = umin.min(uv.x);
umax = umax.max(uv.x);
vmin = vmin.min(uv.y);
vmax = vmax.max(uv.y);
}
let qu = ((umax - umin).abs() * 1e-9).max(1e-12);
let qv = ((vmax - vmin).abs() * 1e-9).max(1e-12);
let mut corner = |uv: (f64, f64)| -> usize {
let k = ((uv.0 / qu).round() as i64, (uv.1 / qv).round() as i64);
*ids.entry(k).or_insert_with(|| {
corner_uv.push(uv);
corner_uv.len() - 1
})
};
let mut base: Vec<[((f64, f64), mt::Point3); 3]> = Vec::new();
for t in pm.faces().triangle_iter() {
let mut c = [((0.0, 0.0), mt::Point3::new(0.0, 0.0, 0.0)); 3];
for (k, v) in t.iter().enumerate() {
let Some(uv) = v.uv.and_then(|i| uvs.get(i)) else { return Err(KernelError::Failed("mesh without parameters".into())) };
let p = pos.get(v.pos).copied().unwrap_or(mt::Point3::new(0.0, 0.0, 0.0));
c[k] = ((uv.x, uv.y), p);
}
base.push(c);
}
// One division for every triangle, so shared sides divide alike.
let longest = base.iter().flat_map(|c| (0..3).map(move |k| c[k].1.distance(c[(k + 1) % 3].1))).fold(0.0, f64::max);
let n = ((longest / step).ceil() as usize).clamp(1, 48);
let mut tris: Vec<[(usize, (f64, f64)); 3]> = Vec::new();
for c in base {
let at = |i: usize, j: usize| {
let (a, b, cc) = (c[0].0, c[1].0, c[2].0);
let (s, t) = (i as f64 / n as f64, j as f64 / n as f64);
(a.0 + (b.0 - a.0) * s + (cc.0 - a.0) * t, a.1 + (b.1 - a.1) * s + (cc.1 - a.1) * t)
};
for i in 0..n {
for j in 0..n - i {
let (p0, p1, p2) = (at(i, j), at(i + 1, j), at(i, j + 1));
tris.push([(corner(p0), p0), (corner(p1), p1), (corner(p2), p2)]);
if i + j + 1 < n {
let p3 = at(i + 1, j + 1);
tris.push([(corner(p1), p1), (corner(p3), p3), (corner(p2), p2)]);
}
}
}
if tris.len() > 4_000_000 {
return Err(KernelError::Failed("the face is too finely divided to trace".into()));
}
}
let gv: Vec<f64> = corner_uv.iter().map(|uv| g.eval(from_p3(surface.subs(uv.0, uv.1)))).collect();
if gv.iter().any(|x| !x.is_finite()) {
return Err(KernelError::Failed("the splitting tool cannot be evaluated on the face".into()));
}
let side = |i: usize| gv.get(i).copied().unwrap_or(0.0) >= 0.0;
// Mesh edges crossed by the zero set, and which triangles link them.
let key = |a: usize, b: usize| (a.min(b), a.max(b));
let mut node_uv: HashMap<(usize, usize), ((f64, f64), (f64, f64))> = HashMap::new();
let mut links: HashMap<(usize, usize), Vec<(usize, usize)>> = HashMap::new();
let mut edge_tris: HashMap<(usize, usize), usize> = HashMap::new();
for t in &tris {
for k in 0..3 {
let (a, b) = (t[k], t[(k + 1) % 3]);
*edge_tris.entry(key(a.0, b.0)).or_default() += 1;
}
let crossed: Vec<(usize, usize)> = (0..3)
.filter_map(|k| {
let (a, b) = (t[k], t[(k + 1) % 3]);
(side(a.0) != side(b.0)).then(|| {
let kk = key(a.0, b.0);
let (ua, ub) = if a.0 < b.0 { (a.1, b.1) } else { (b.1, a.1) };
node_uv.entry(kk).or_insert((ua, ub));
kk
})
})
.collect();
if let [x, y] = crossed[..] {
links.entry(x).or_default().push(y);
links.entry(y).or_default().push(x);
}
}
// Exact point on a crossed mesh edge: bisection along its parameter segment.
let exact = |k: (usize, usize)| -> Option<(mt::Point3, (f64, f64))> {
let (ua, ub) = *node_uv.get(&k)?;
let f = |s: f64| {
let uv = (ua.0 + (ub.0 - ua.0) * s, ua.1 + (ub.1 - ua.1) * s);
(g.eval(from_p3(surface.subs(uv.0, uv.1))), uv)
};
let (mut lo, mut hi) = (0.0, 1.0);
let s0 = f(0.0).0 >= 0.0;
for _ in 0..48 {
let mid = 0.5 * (lo + hi);
if (f(mid).0 >= 0.0) == s0 {
lo = mid;
} else {
hi = mid;
}
}
let uv = f(0.5 * (lo + hi)).1;
Some((surface.subs(uv.0, uv.1), uv))
};
// Walk the links into chains.
let mut seen: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
let mut chains = Vec::new();
let mut nodes: Vec<(usize, usize)> = links.keys().copied().collect();
nodes.sort();
// Open chains start at mesh boundary edges (one triangle).
nodes.sort_by_key(|k| edge_tris.get(k).copied().unwrap_or(0) != 1);
for start in nodes {
if seen.contains(&start) {
continue;
}
let mut path = vec![start];
seen.insert(start);
let mut cur = start;
let closed;
loop {
let next = links.get(&cur).into_iter().flatten().find(|n| !seen.contains(*n)).copied();
match next {
Some(n) => {
seen.insert(n);
path.push(n);
cur = n;
}
None => {
closed = path.len() > 2 && links.get(&cur).is_some_and(|l| l.contains(&start));
break;
}
}
if path.len() > 1_000_000 {
return Err(KernelError::Failed("split line too long".into()));
}
}
// Points about a step apart (the ends kept).
let all: Vec<_> = path.iter().filter_map(|k| exact(*k)).collect();
let mut pts: Vec<(mt::Point3, (f64, f64))> = Vec::new();
for (i, p) in all.iter().enumerate() {
let keep = i == 0 || i + 1 == all.len() || pts.last().is_none_or(|l| l.0.distance(p.0) >= step * 0.5);
if keep {
pts.push(*p);
}
}
if pts.len() >= 2 {
chains.push(Chain { pts, closed });
}
}
Ok(chains)
}
/// Where the zero set crosses a boundary edge of the face (exact, by bisection on the curve).
fn boundary_hits(face: &mt::Face, g: &Field) -> Vec<Hit> {
let mut out = Vec::new();
for w in face.absolute_boundaries() {
for e in w.edge_iter() {
let c = e.curve();
let (t0, t1) = c.range_tuple();
let n = 64;
let val = |t: f64| g.eval(from_p3(c.subs(t)));
let mut prev = (t0, val(t0));
for k in 1..=n {
let t = t0 + (t1 - t0) * k as f64 / n as f64;
let cur = (t, val(t));
if (prev.1 >= 0.0) != (cur.1 >= 0.0) {
let (mut lo, mut hi) = (prev.0, cur.0);
let s0 = prev.1 >= 0.0;
for _ in 0..60 {
let mid = 0.5 * (lo + hi);
if (val(mid) >= 0.0) == s0 {
lo = mid;
} else {
hi = mid;
}
}
let t = 0.5 * (lo + hi);
out.push(Hit { edge: e.clone(), t, p: c.subs(t) });
}
prev = cur;
}
}
}
out
}
/// A curve through points: a line when they are collinear, else a cubic through every point
/// (Catmull-Rom tangents; `closed` when the last point repeats the first).
fn fit(pts: &[mt::Point3], scale: f64) -> Result<mt::Curve> {
let (Some(a), Some(b)) = (pts.first(), pts.last()) else { return Err(KernelError::Failed("empty split line".into())) };
let d = b - a;
let len = d.magnitude();
let straight = len > 0.0 && pts.iter().all(|p| (p - a).cross(d).magnitude() / len <= scale * 1e-9);
if straight {
return Ok(mt::Curve::Line(mt::Line(*a, *b)));
}
let m = pts.len().saturating_sub(1);
if m < 1 {
return Err(KernelError::Failed("split line too short".into()));
}
let closed = a.distance(*b) <= scale * 1e-9 && m >= 3;
let at = |i: isize| -> mt::Point3 {
let i = if closed { i.rem_euclid(m as isize) as usize } else { i.clamp(0, m as isize) as usize };
pts.get(i).copied().unwrap_or(*a)
};
let mut ctrl = vec![*a];
let mut knots = vec![0.0; 4];
for i in 0..m as isize {
let (p0, p1) = (at(i), at(i + 1));
let t0 = (at(i + 1) - at(i - 1)) / 6.0;
let t1 = (at(i + 2) - at(i)) / 6.0;
ctrl.extend([p0 + t0, p1 - t1, p1]);
let k = (i + 1) as f64;
knots.extend(if i + 1 == m as isize { [k; 4].to_vec() } else { [k; 3].to_vec() });
}
let bsp = mt::BSplineCurve::try_new(mt::KnotVec::from(knots), ctrl).map_err(|e| KernelError::Failed(format!("split line: {e}")))?;
Ok(mt::Curve::BSplineCurve(bsp))
}
/// Cut an edge at a parameter (none when the point is already a vertex): the vertex and the
/// pieces in the edge's absolute direction.
fn cut_edge(e: &mt::Edge, t: f64, p: mt::Point3, tol: f64) -> Result<(mt::Vertex, Option<(mt::Edge, mt::Edge)>)> {
let abs = if e.orientation() { e.clone() } else { e.inverse() };
if abs.front().point().distance2(p) <= tol * tol {
return Ok((abs.front().clone(), None));
}
if abs.back().point().distance2(p) <= tol * tol {
return Ok((abs.back().clone(), None));
}
let v = mt::Vertex::new(p);
let (a, b) = abs.cut_with_parameter(&v, t).ok_or_else(|| KernelError::Failed("cannot cut an edge at the split line".into()))?;
Ok((v, Some((a, b))))
}
/// Replace cut edges (absolute edge → its two pieces) in a face's loops.
fn recut(face: &mt::Face, cuts: &[(mt::Edge, (mt::Edge, mt::Edge))]) -> mt::Face {
let touches = face.absolute_boundaries().iter().flat_map(|w| w.edge_iter()).any(|e| cuts.iter().any(|(c, _)| c.id() == e.id()));
if !touches {
return face.clone();
}
let wires: Vec<mt::Wire> = face
.absolute_boundaries()
.iter()
.map(|w| {
let mut out: Vec<mt::Edge> = Vec::new();
for e in w.edge_iter() {
match cuts.iter().find(|(c, _)| c.id() == e.id()) {
Some((c, (a, b))) if c.orientation() == e.orientation() => out.extend([a.clone(), b.clone()]),
Some((_, (a, b))) => out.extend([b.inverse(), a.inverse()]),
None => out.push(e.clone()),
}
}
out.into()
})
.collect();
let mut f = mt::Face::new_unchecked(wires, face.surface());
if !face.orientation() {
f.invert();
}
f
}
/// A face rebuilt from loops (absolute) on the same surface and orientation as `like`.
fn face_like(like: &mt::Face, wires: Vec<mt::Wire>) -> mt::Face {
let mut f = mt::Face::new_unchecked(wires, like.surface());
if !like.orientation() {
f.invert();
}
f
}
/// Polygon of a loop in the surface's parameters (for inside tests).
fn loop_uv(s: &mt::Surface, w: &mt::Wire) -> Vec<(f64, f64)> {
let mut out = Vec::new();
let mut hint = None;
for e in w.edge_iter() {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
for k in 0..8 {
let p = c.subs(t0 + (t1 - t0) * k as f64 / 8.0);
if let Some(uv) = s.search_nearest_parameter(p, hint, 50) {
hint = Some(uv);
out.push(uv);
}
}
}
out
}
fn inside(poly: &[(f64, f64)], q: (f64, f64)) -> bool {
let mut c = false;
let n = poly.len();
for i in 0..n {
let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
if (a.1 > q.1) != (b.1 > q.1) && q.0 < a.0 + (b.0 - a.0) * (q.1 - a.1) / (b.1 - a.1) {
c = !c;
}
}
c
}
/// Split one face (index `fi` in `faces`) by one field; `faces` is updated (the face replaced
/// by its pieces, boundary edges cut in every face).
fn split_one(faces: &mut Vec<mt::Face>, fi: usize, g: &Field, size: f64) -> Result<usize> {
let Some(face) = faces.get(fi).cloned() else { return Ok(0) };
let chains = trace(&face, g, size)?;
if chains.is_empty() {
return Ok(0);
}
let tol = size * 1e-7 + 1e-9;
let snap = size * 1e-3;
let hits = boundary_hits(&face, g);
// Pieces of the original face as they are split.
let mut pieces: Vec<mt::Face> = vec![face.clone()];
let mut all_cuts: Vec<(mt::Edge, (mt::Edge, mt::Edge))> = Vec::new();
let mut made = 0;
for ch in chains {
if ch.closed {
// A closed line inside the face: a new face inside it and a hole in the piece that holds it.
let mut pts: Vec<mt::Point3> = ch.pts.iter().map(|x| x.0).collect();
if let Some(first) = pts.first().copied() {
pts.push(first);
}
let curve = fit(&pts, size)?;
let Some(&(p0, uv0)) = ch.pts.first() else { continue };
let v = mt::Vertex::new(p0);
let e = mt::Edge::new_unchecked(&v, &v, curve);
let uv: Vec<(f64, f64)> = ch.pts.iter().map(|x| x.1).collect();
let area: f64 = (0..uv.len())
.map(|i| {
let (a, b) = (uv[i], uv[(i + 1) % uv.len()]);
a.0 * b.1 - b.0 * a.1
})
.sum();
let s = face.surface();
let Some(pi) = pieces.iter().position(|f| {
let ws = f.absolute_boundaries();
ws.first().is_some_and(|o| inside(&loop_uv(&s, o), uv0)) && !ws.iter().skip(1).any(|h| inside(&loop_uv(&s, h), uv0))
}) else {
continue;
};
let Some(host) = pieces.get(pi).cloned() else { continue };
// The new loop counter-clockwise in parameters (an outer loop); its reverse a hole.
let ccw: mt::Wire = if area > 0.0 { vec![e.clone()].into() } else { vec![e.inverse()].into() };
let hole: mt::Wire = ccw.inverse();
let ws = host.absolute_boundaries().clone();
let mut outer_ws = vec![];
let mut inner_ws = vec![ccw.clone()];
for (k, w) in ws.iter().enumerate() {
let q = loop_uv(&s, w).first().copied();
if k > 0 && q.is_some_and(|q| inside(&uv, q)) {
inner_ws.push(w.clone());
} else {
outer_ws.push(w.clone());
}
}
outer_ws.push(hole);
let a = face_like(&host, outer_ws);
let b = face_like(&host, inner_ws);
pieces.splice(pi..=pi, [a, b]);
made += 1;
continue;
}
// An open line: it must end on the boundary at two crossings.
let (Some(&(pa, _)), Some(&(pb, _))) = (ch.pts.first(), ch.pts.last()) else { continue };
let near =
|p: mt::Point3| hits.iter().filter(|h| h.p.distance(p) <= snap).min_by(|x, y| x.p.distance(p).total_cmp(&y.p.distance(p))).cloned();
let (Some(ha), Some(hb)) = (near(pa), near(pb)) else { continue };
// Cut the boundary edges (an edge may already have been cut by an earlier line).
let vert = |h: &Hit, cuts: &mut Vec<(mt::Edge, (mt::Edge, mt::Edge))>| -> Result<mt::Vertex> {
// The current edge holding the hit: the original, or a piece of it.
let mut e = h.edge.clone();
for (c, (x, y)) in cuts.iter() {
if c.id() == e.id() || c.id() == e.inverse().id() {
let on = |pc: &mt::Edge| {
let cc = pc.curve();
let (t0, t1) = cc.range_tuple();
(t0.min(t1) - 1e-12..=t0.max(t1) + 1e-12).contains(&h.t)
};
e = if on(x) { x.clone() } else { y.clone() };
}
}
let (v, cut) = cut_edge(&e, h.t, h.p, tol)?;
if let Some(pair) = cut {
let abs = if e.orientation() { e.clone() } else { e.inverse() };
cuts.push((abs, pair));
}
Ok(v)
};
let va = vert(&ha, &mut all_cuts)?;
let vb = vert(&hb, &mut all_cuts)?;
if va.id() == vb.id() {
continue;
}
pieces = pieces.iter().map(|f| recut(f, &all_cuts)).collect();
let mut pts: Vec<mt::Point3> = ch.pts.iter().map(|x| x.0).collect();
if let Some(f) = pts.first_mut() {
*f = va.point();
}
if let Some(l) = pts.last_mut() {
*l = vb.point();
}
// A line from one loop of a piece to another (into a hole): the two loops become one,
// joined by the line run both ways; a later line divides it.
let in_loop = |w: &mt::Wire, v: &mt::Vertex| w.edge_iter().any(|e| e.front().id() == v.id());
if let Some(pi) = pieces.iter().position(|f| {
let ws = f.absolute_boundaries();
ws.iter().any(|w| in_loop(w, &va)) && ws.iter().any(|w| in_loop(w, &vb)) && !ws.iter().any(|w| in_loop(w, &va) && in_loop(w, &vb))
}) && let Some(host) = pieces.get(pi).cloned()
{
let ws = host.absolute_boundaries().clone();
let (Some(wa_i), Some(wb_i)) = (ws.iter().position(|w| in_loop(w, &va)), ws.iter().position(|w| in_loop(w, &vb))) else { continue };
let from = |w: &mt::Wire, v: &mt::Vertex| -> Vec<mt::Edge> {
let edges: Vec<mt::Edge> = w.edge_iter().cloned().collect();
let k = edges.iter().position(|e| e.front().id() == v.id()).unwrap_or(0);
edges.iter().skip(k).chain(edges.iter().take(k)).cloned().collect()
};
let (Some(w1), Some(w2)) = (ws.get(wa_i), ws.get(wb_i)) else { continue };
let slit = mt::Edge::new_unchecked(&va, &vb, fit(&pts, size)?);
let mut joined = from(w1, &va);
joined.push(slit.clone());
joined.extend(from(w2, &vb));
joined.push(slit.inverse());
// The outer loop first.
let others = ws.iter().enumerate().filter(|(k, _)| *k != wa_i && *k != wb_i).map(|(_, w)| w.clone());
let joined: mt::Wire = joined.into();
let loops: Vec<mt::Wire> = if wa_i == 0 || wb_i == 0 {
std::iter::once(joined).chain(others).collect()
} else {
others.chain(std::iter::once(joined)).collect()
};
pieces.splice(pi..=pi, [face_like(&host, loops)]);
continue;
}
// The piece whose loop passes both vertices.
let Some(pi) = pieces.iter().position(|f| {
f.absolute_boundaries().iter().any(|w| {
let ids: Vec<_> = w.edge_iter().map(|e| e.front().id()).collect();
ids.contains(&va.id()) && ids.contains(&vb.id())
})
}) else {
continue;
};
let Some(host) = pieces.get(pi).cloned() else { continue };
let ws = host.absolute_boundaries().clone();
let Some(wi) = ws.iter().position(|w| {
let ids: Vec<_> = w.edge_iter().map(|e| e.front().id()).collect();
ids.contains(&va.id()) && ids.contains(&vb.id())
}) else {
continue;
};
let Some(w) = ws.get(wi) else { continue };
let edges: Vec<mt::Edge> = w.edge_iter().cloned().collect();
let n = edges.len();
let (Some(ia), Some(ib)) = (edges.iter().position(|e| e.front().id() == va.id()), edges.iter().position(|e| e.front().id() == vb.id()))
else {
continue;
};
let split = mt::Edge::new_unchecked(&va, &vb, fit(&pts, size)?);
let run = |from: usize, to: usize| -> Vec<mt::Edge> {
let mut out = Vec::new();
let mut i = from;
while i != to {
if let Some(e) = edges.get(i) {
out.push(e.clone());
}
i = (i + 1) % n;
}
out
};
// Piece A: a → b along the loop, back along the split. Piece B: b → a, then the split.
let mut wa = run(ia, ib);
wa.push(split.inverse());
let mut wb = run(ib, ia);
wb.push(split.clone());
let (wa, wb): (mt::Wire, mt::Wire) = (wa.into(), wb.into());
if !wa.is_closed() || !wb.is_closed() {
return Err(KernelError::Failed("split loops do not close".into()));
}
// Other loops go to the piece on their side.
let s = face.surface();
let (pa_poly, pb_poly) = (loop_uv(&s, &wa), loop_uv(&s, &wb));
let (mut la, mut lb) = (vec![wa], vec![wb]);
for (k, other) in ws.iter().enumerate() {
if k == wi {
continue;
}
let q = loop_uv(&s, other).first().copied();
match q {
Some(q) if inside(&pa_poly, q) => la.push(other.clone()),
Some(q) if inside(&pb_poly, q) => lb.push(other.clone()),
_ => return Err(KernelError::Failed("a hole meets the split line".into())),
}
}
let (a, b) = (face_like(&host, la), face_like(&host, lb));
pieces.splice(pi..=pi, [a, b]);
made += 1;
}
if made == 0 {
return Ok(0);
}
// Neighbours take the cut edges.
let rest: Vec<mt::Face> = faces.iter().enumerate().filter(|(k, _)| *k != fi).map(|(_, f)| recut(f, &all_cuts)).collect();
let mut out = Vec::with_capacity(rest.len() + pieces.len());
out.extend(rest.iter().take(fi).cloned());
out.extend(pieces);
out.extend(rest.iter().skip(fi).cloned());
*faces = out;
Ok(made)
}
/// Split the given faces (indices in `body`'s face order) where the tool meets them. The solid
/// is unchanged; faces the tool misses stay whole.
pub fn split_faces(body: &Body, faces: &[usize], tool: &SplitTool) -> Result<Body> {
body.require_brep("split face")?;
let size = body.size();
let fields: Vec<Field> = match tool {
SplitTool::Plane(p) => {
let n = p.normal();
vec![Field::Plane { o: p.origin, n }]
}
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())]
}
SplitTool::Curves { plane, curves } => {
if curves.iter().any(|c| c.len() < 2 || c.iter().any(|p| !(p.x.is_finite() && p.y.is_finite()))) {
return Err(KernelError::Invalid("split curves need at least two finite points each".into()));
}
curves.iter().map(|c| Field::Curve { plane: *plane, pts: c.clone() }).collect()
}
};
let total = body.solid.face_iter().count();
if faces.iter().any(|f| *f >= total) {
return Err(KernelError::Invalid("no such face".into()));
}
guard("split face", || {
let shells: Vec<mt::Shell> = body.solid.boundaries().clone();
// Faces to split, by shell, tracked as faces are replaced by pieces.
let mut out_shells = Vec::new();
let mut base = 0;
let mut made = 0;
for sh in shells {
let mut fs: Vec<mt::Face> = sh.face_iter().cloned().collect();
let mut targets: Vec<mt::FaceID> =
faces.iter().filter(|f| **f >= base && **f < base + fs.len()).filter_map(|f| fs.get(*f - base).map(mt::Face::id)).collect();
base += fs.len();
for g in &fields {
let mut next_targets = Vec::new();
for id in targets.clone() {
let Some(fi) = fs.iter().position(|f| f.id() == id) else { continue };
let before: Vec<mt::FaceID> = fs.iter().map(mt::Face::id).collect();
made += split_one(&mut fs, fi, g, size)?;
// The face and any pieces of it stay targets for the next curve.
for f in &fs {
if !before.contains(&f.id()) || f.id() == id {
next_targets.push(f.id());
}
}
}
// Recut neighbours change ids too: keep pieces only.
targets = next_targets;
}
out_shells.push(mt::Shell::from(fs));
}
if made == 0 {
return Err(KernelError::Invalid("the tool does not cross the selected faces".into()));
}
let solid = mt::Solid::new_unchecked(out_shells);
Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: body.color, paint: None })
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{box_solid, cylinder, measure};
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-9)
}
fn top(b: &Body, z: f64) -> usize {
b.faces(0.01).unwrap().iter().position(|f| (f.centroid.z - z).abs() < 1e-6 && f.plane_normal.is_some()).unwrap()
}
#[test]
fn plane_splits_a_box_top() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let m0 = measure(&b).unwrap();
let pl = Plane::new(Vec3::new(4.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
let s = split_faces(&b, &[top(&b, 30.0)], &SplitTool::Plane(pl)).unwrap();
assert_eq!(s.face_count(), 7);
let m = measure(&s).unwrap();
assert!(rel(m.volume, m0.volume) < 1e-9 && rel(m.area, m0.area) < 1e-9);
let mut tops: Vec<f64> = s.faces(0.01).unwrap().iter().filter(|f| (f.centroid.z - 30.0).abs() < 1e-6).map(|f| f.area).collect();
tops.sort_by(f64::total_cmp);
assert!((tops[0] - 80.0).abs() < 1e-6 && (tops[1] - 120.0).abs() < 1e-6, "{tops:?}");
// The side faces the line ends on gained a vertex but kept their area.
assert!(s.faces(0.01).unwrap().iter().all(|f| f.area > 0.0));
// Later features work on the split body: a hole through the split top.
let pin = cylinder(Vec3::new(5.0, 10.0, -1.0), Vec3::Z, 2.0, 40.0).unwrap();
let holed = crate::boolean(&s, &pin, crate::BoolOp::Cut).unwrap().unwrap();
let v = measure(&holed).unwrap().volume;
assert!(rel(v, 6000.0 - std::f64::consts::PI * 4.0 * 30.0) < 1e-3, "{v}");
}
#[test]
fn sketch_curve_splits_a_cylinder_side_and_a_circle_splits_a_top() {
let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
let m0 = measure(&c).unwrap();
let side = c.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap();
// A horizontal line seen from the front (XZ sketch) at z = 5, swept along y.
let xz = Plane::XZ;
let tool = SplitTool::Curves { plane: xz, curves: vec![vec![Vec2::new(-20.0, 5.0), Vec2::new(20.0, 5.0)]] };
let s = split_faces(&c, &[side], &tool).unwrap();
let m = measure(&s).unwrap();
// (Measured on meshes: curved faces agree to about 1e-4.)
assert!(rel(m.volume, m0.volume) < 2e-4 && rel(m.area, m0.area) < 2e-4, "{} {} {} {}", m.volume, m0.volume, m.area, m0.area);
// The half side face splits at z = 5: a quarter and three quarters of its area.
let mut sides: Vec<f64> = s.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_none()).map(|f| f.area).collect();
sides.sort_by(f64::total_cmp);
let half = std::f64::consts::PI * 10.0 * 20.0;
assert_eq!(sides.len(), 3);
assert!(rel(sides[0], half / 4.0) < 1e-3 && rel(sides[1], half * 0.75) < 1e-3 && rel(sides[2], half) < 1e-3, "{sides:?}");
assert!(s.face_count() > c.face_count());
// A smaller cylinder's side splits the top in a ring and a disc.
let pin = cylinder(Vec3::ZERO, Vec3::Z, 4.0, 40.0).unwrap();
let pin_side = pin.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap();
let s = split_faces(&c, &[top(&c, 20.0)], &SplitTool::Face { body: &pin, face: pin_side }).unwrap();
let m = measure(&s).unwrap();
assert!(rel(m.volume, m0.volume) < 2e-4 && rel(m.area, m0.area) < 2e-4, "{} {}", m.area, m0.area);
let mut tops: Vec<f64> = s.faces(0.01).unwrap().iter().filter(|f| (f.centroid.z - 20.0).abs() < 1e-6).map(|f| f.area).collect();
tops.sort_by(f64::total_cmp);
let pi = std::f64::consts::PI;
assert!(rel(tops[0], pi * 16.0) < 1e-3 && rel(tops[1], pi * 84.0) < 1e-3, "{tops:?}");
}
/// A plane through a hole in a plate's top: the top splits in two, each piece holding
/// half of the hole's rim.
#[test]
fn plane_through_a_hole_splits_the_face() {
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 b = crate::boolean(&plate, &pin, crate::BoolOp::Cut).unwrap().unwrap();
let m0 = measure(&b).unwrap();
let pl = Plane::new(Vec3::new(20.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
let s = split_faces(&b, &[top(&b, 10.0)], &SplitTool::Plane(pl)).unwrap();
let m = measure(&s).unwrap();
assert!(rel(m.volume, m0.volume) < 1e-4 && rel(m.area, m0.area) < 1e-4);
let tops: Vec<f64> = s.faces(0.01).unwrap().iter().filter(|f| (f.centroid.z - 10.0).abs() < 1e-6).map(|f| f.area).collect();
let half = (1200.0 - std::f64::consts::PI * 25.0) / 2.0;
assert_eq!(tops.len(), 2, "{tops:?}");
assert!(tops.iter().all(|a| rel(*a, half) < 1e-3), "{tops:?}");
}
#[test]
fn a_tool_that_misses_is_an_error() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let pl = Plane::new(Vec3::new(50.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
assert!(split_faces(&b, &[0], &SplitTool::Plane(pl)).is_err());
assert!(split_faces(&b, &[99], &SplitTool::Plane(Plane::XY)).is_err());
}
}