kernel::delete_faces(body, faces): Delete Face with healing. The body is rebuilt from its own topology: faces away from the gap stay, planar neighbours keep their other edges, loops that ran along deleted faces (hole rims, boss footprints) go, corners on the gap move to where the neighbouring planes meet across it, and the gap closes with straight edges. Fillets, chamfers, through and blind holes and bosses come off exactly; curved neighbours and gaps the neighbours cannot close are refused naming the face

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:10:06 +00:00
1 parent b8cd3d71e5
commit 7e2d45602b
2 files changed
+345

No files matched your search

+343
View File
@@ -0,0 +1,343 @@
//! Delete Face with healing: faces are removed and their planar neighbours extend to close the
//! gap, as when a fillet, chamfer, boss or hole is taken off a part.
//!
//! The body is rebuilt from its own topology. Faces away from the gap are kept as they are.
//! Each neighbouring face (planar) keeps its edges that don't touch the gap; a loop that ran
//! entirely along deleted faces (a hole's rim, a boss's footprint) is dropped; a corner on the
//! gap moves to where its planes meet the neighbours across the gap (a fillet's tangent points
//! go to the sharp corner), and the stretch of loop that bordered deleted faces closes with a
//! straight edge between the moved corners. Anything else (curved neighbours, a gap the
//! neighbours cannot close) is refused, naming the face.
use std::collections::{BTreeSet, HashMap};
use solvecraft_geom::Vec3;
use truck_modeling::{self as mt, builder};
use crate::body::{Body, Solid, from_p3, p3};
use crate::{KernelError, Result, guard};
fn fail(m: impl Into<String>) -> KernelError {
KernelError::Invalid(format!("Delete Face: {}", m.into()))
}
/// Outward plane (unit normal, offset) of a face, if it is planar: sampled along its edges,
/// the surface's outward normal stays the same and the points stay on one plane.
fn face_plane(f: &mt::Face, tol: f64) -> Option<(Vec3, f64)> {
use mt::{BoundedCurve, ParametricCurve, ParametricSurface3D, SearchNearestParameter};
let surf = f.oriented_surface();
let mut pts: Vec<(Vec3, Vec3)> = Vec::new();
let mut last: Option<(f64, f64)> = None;
for w in f.boundaries() {
for e in w.edge_iter() {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
for k in 0..6 {
let p = c.subs(t0 + (t1 - t0) * k as f64 / 6.0);
let (u, v) = surf.search_nearest_parameter(p, last, 50)?;
last = Some((u, v));
let n = surf.normal(u, v);
pts.push((from_p3(p), Vec3::new(n.x, n.y, n.z).normalized()?));
}
}
}
let (p0, n0) = *pts.first()?;
let d = n0.dot(p0);
pts.iter().all(|(p, n)| n.dot(n0) > 1.0 - 1e-9 && (n0.dot(*p) - d).abs() <= tol).then_some((n0, d))
}
type Key = (i64, i64, i64);
/// Key of a point for sharing vertices and edges between rebuilt faces.
fn key(p: Vec3, q: f64) -> Key {
let r = |x: f64| (x / q).round() as i64;
(r(p.x), r(p.y), r(p.z))
}
/// Delete faces (indices in the body's face order, as in its mesh's `tri_face`) and heal the
/// gap by extending the neighbouring planes.
pub fn delete_faces(body: &Body, faces: &[usize]) -> Result<Body> {
body.require_brep("Delete Face")?;
let all: Vec<mt::Face> = body.solid.face_iter().cloned().collect();
let n = all.len();
if faces.is_empty() {
return Err(fail("no faces given"));
}
let del: BTreeSet<usize> = faces.iter().copied().collect();
if let Some(bad) = del.iter().find(|f| **f >= n) {
return Err(fail(format!("there is no face {bad} (the body has {n})")));
}
if del.len() >= n {
return Err(fail("that would delete every face"));
}
let size = body.size().max(1e-9);
// Edge and vertex → faces.
let mut efaces: HashMap<mt::EdgeID, Vec<usize>> = HashMap::new();
let mut vfaces: HashMap<mt::VertexID, Vec<usize>> = HashMap::new();
for (i, f) in all.iter().enumerate() {
for e in f.edge_iter() {
let v = efaces.entry(e.id()).or_default();
if !v.contains(&i) {
v.push(i);
}
}
for v in f.vertex_iter() {
let l = vfaces.entry(v.id()).or_default();
if !l.contains(&i) {
l.push(i);
}
}
}
// Neighbours: faces sharing an edge with a deleted face; they must be planar.
let mut neighbours: BTreeSet<usize> = BTreeSet::new();
for fs in efaces.values() {
if fs.iter().any(|f| del.contains(f)) {
neighbours.extend(fs.iter().filter(|f| !del.contains(f)));
}
}
if neighbours.is_empty() {
return Err(fail("the faces don't border the rest of the body"));
}
let mut planes: HashMap<usize, (Vec3, f64)> = HashMap::new();
for &f in &neighbours {
// Planar by its geometry (boolean results may carry a plane as another surface type).
match all.get(f).and_then(|x| face_plane(x, size * 1e-6)) {
Some(p) => {
planes.insert(f, p);
}
None => {
let by = efaces
.values()
.find(|fs| fs.contains(&f) && fs.iter().any(|x| del.contains(x)))
.and_then(|fs| fs.iter().find(|x| del.contains(x)).copied())
.unwrap_or(0);
return Err(fail(format!("face {by} borders face {f}, which is curved; only planar neighbours can extend to close the gap yet")));
}
}
}
// Corners across the gap: three neighbour planes meeting near the deleted faces.
let (mut lo, mut hi) =
(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY));
for &f in &del {
for v in all.get(f).into_iter().flat_map(|x| x.vertex_iter()) {
let p = from_p3(v.point());
lo = lo.min(p);
hi = hi.max(p);
}
}
let reach = (hi - lo).len().max(size * 1e-3);
let near = |p: Vec3| {
p.x >= lo.x - reach && p.y >= lo.y - reach && p.z >= lo.z - reach && p.x <= hi.x + reach && p.y <= hi.y + reach && p.z <= hi.z + reach
};
let on = |p: Vec3, f: usize| planes.get(&f).is_some_and(|(n, d)| (n.dot(p) - d).abs() <= size * 1e-6);
let nb: Vec<usize> = neighbours.iter().copied().collect();
let mut corners: Vec<Vec3> = Vec::new();
for i in 0..nb.len() {
for j in (i + 1)..nb.len() {
for k in (j + 1)..nb.len() {
let (Some(a), Some(b), Some(c)) =
(nb.get(i).and_then(|x| planes.get(x)), nb.get(j).and_then(|x| planes.get(x)), nb.get(k).and_then(|x| planes.get(x)))
else {
continue;
};
let den = a.0.dot(b.0.cross(c.0));
if den.abs() < 1e-9 {
continue;
}
let x = (b.0.cross(c.0) * a.1 + c.0.cross(a.0) * b.1 + a.0.cross(b.0) * c.1) / den;
if x.is_finite() && near(x) {
corners.push(x);
}
}
}
}
// Where each vertex on the gap goes: the nearest corner on all of its remaining planes.
let mut moved: HashMap<mt::VertexID, Vec3> = HashMap::new();
for &f in &del {
for v in all.get(f).into_iter().flat_map(|x| x.vertex_iter()) {
let Some(fs) = vfaces.get(&v.id()) else { continue };
let keep: Vec<usize> = fs.iter().copied().filter(|x| !del.contains(x)).collect();
if keep.is_empty() || keep.len() >= 3 {
continue;
}
if keep.iter().any(|x| !planes.contains_key(x)) {
return Err(fail(format!("a corner of face {f} touches a face that cannot move")));
}
let p = from_p3(v.point());
let best = corners.iter().filter(|c| keep.iter().all(|x| on(**c, *x))).min_by(|a, b| a.dist(p).total_cmp(&b.dist(p)));
if let Some(c) = best {
moved.insert(v.id(), *c);
}
}
}
let q = size * 1e-7;
guard("delete face", || {
// Shared vertices and new straight edges between the rebuilt faces.
let mut verts: HashMap<Key, mt::Vertex> = HashMap::new();
for v in body.solid.vertex_iter() {
if !moved.contains_key(&v.id()) {
verts.insert(key(from_p3(v.point()), q), v.clone());
}
}
let mut lines: HashMap<(Key, Key), mt::Edge> = HashMap::new();
let mut line = |a: Vec3, b: Vec3, verts: &mut HashMap<Key, mt::Vertex>| -> mt::Edge {
let (ka, kb) = (key(a, q), key(b, q));
if let Some(e) = lines.get(&(ka, kb)) {
return e.clone();
}
if let Some(e) = lines.get(&(kb, ka)) {
return e.inverse();
}
let va = verts.entry(ka).or_insert_with(|| builder::vertex(p3(a))).clone();
let vb = verts.entry(kb).or_insert_with(|| builder::vertex(p3(b))).clone();
let e = builder::line(&va, &vb);
lines.insert((ka, kb), e.clone());
e
};
let mut out: Vec<mt::Face> = Vec::new();
for (fi, f) in all.iter().enumerate() {
if del.contains(&fi) {
continue;
}
if !neighbours.contains(&fi) {
// Untouched, unless one of its corners moves.
if f.vertex_iter().any(|v| moved.contains_key(&v.id())) {
return Err(fail(format!("face {fi} meets the gap only at a corner; it cannot follow")));
}
out.push(f.clone());
continue;
}
let pos = |v: &mt::Vertex| moved.get(&v.id()).copied().unwrap_or_else(|| from_p3(v.point()));
let gap = |e: &mt::Edge| efaces.get(&e.id()).is_some_and(|fs| fs.iter().any(|x| del.contains(x)));
let mut wires: Vec<mt::Wire> = Vec::new();
for w in f.boundaries() {
let edges: Vec<mt::Edge> = w.edge_iter().cloned().collect();
if edges.iter().all(gap) {
continue;
}
// Start after a gap stretch so stretches don't wrap around.
let m = edges.len();
let start = (0..m).find(|i| edges.get((i + m - 1) % m).is_some_and(gap) && edges.get(*i).is_some_and(|e| !gap(e))).unwrap_or(0);
let mut new_edges: Vec<mt::Edge> = Vec::new();
let mut pending: Option<Vec3> = None;
for k in 0..m {
let Some(e) = edges.get((start + k) % m) else { continue };
if gap(e) {
pending.get_or_insert(pos(e.front()));
continue;
}
let (a, b) = (pos(e.front()), pos(e.back()));
if let Some(p) = pending.take()
&& p.dist(a) > q * 10.0
{
new_edges.push(line(p, a, &mut verts));
}
let moves = moved.contains_key(&e.front().id()) || moved.contains_key(&e.back().id());
if !moves {
new_edges.push(e.clone());
} else if matches!(e.oriented_curve(), mt::Curve::Line(_)) {
if a.dist(b) > q * 10.0 {
new_edges.push(line(a, b, &mut verts));
}
} else {
return Err(fail(format!("face {fi} has a curved edge that would have to stretch")));
}
}
// A gap stretch at the end closes back to the first edge.
if let (Some(p), Some(first)) = (pending, new_edges.first()) {
let a = from_p3(first.front().point());
if p.dist(a) > q * 10.0 {
new_edges.push(line(p, a, &mut verts));
}
}
if new_edges.is_empty() {
return Err(fail(format!("face {fi} would lose its boundary")));
}
wires.push(new_edges.into());
}
let face = builder::try_attach_plane(&wires).map_err(|e| fail(format!("face {fi} cannot close: {e}")))?;
// Keep the face's outward side.
let (nrm, _) = planes.get(&fi).copied().ok_or_else(|| fail("plane"))?;
let face = match face.surface() {
mt::Surface::Plane(pl) => {
let pn = pl.normal();
let along = Vec3::new(pn.x, pn.y, pn.z).dot(nrm);
if (along > 0.0) == face.orientation() { face } else { face.inverse() }
}
_ => face,
};
out.push(face);
}
let shell: mt::Shell = out.into();
let solid = Solid::try_new(vec![shell]).map_err(|e| fail(format!("the neighbouring faces do not close the gap ({e})")))?;
Body::new(solid)
})
}
#[cfg(test)]
mod tests {
use solvecraft_geom::Vec3;
use super::delete_faces;
use crate::{BoolOp, boolean, box_solid, chamfer, cylinder, fillet};
fn volume(b: &crate::Body) -> f64 {
b.tessellate(1e-3).unwrap().measure().volume
}
/// The faces of `b` with every triangle corner passing `keep`.
fn faces_where(b: &crate::Body, keep: impl Fn(Vec3) -> bool) -> Vec<usize> {
let m = b.tessellate(1e-3).unwrap();
let n = m.tri_face.iter().max().map(|x| *x as usize + 1).unwrap_or(0);
(0..n)
.filter(|f| {
m.triangles.iter().zip(&m.tri_face).filter(|(_, x)| **x as usize == *f).all(|(t, _)| m.tri(t).unwrap().iter().all(|p| keep(*p)))
})
.collect()
}
#[test]
fn delete_fillet_chamfer_hole_and_boss_faces() {
let sharp = 24000.0;
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
// A fillet on a box edge: deleting it gives the sharp box back, exactly.
let f = fillet(&b, &[Vec3::new(20.0, 30.0, 20.0)], 5.0).unwrap();
assert!(volume(&f) < sharp - 1.0);
let fi = faces_where(&f, |p| p.y > 25.0 - 1e-6 && p.z > 15.0 - 1e-6 && (p.y < 30.0 - 1e-9 || p.z < 20.0 - 1e-9));
assert_eq!(fi.len(), 1, "{fi:?}");
let back = delete_faces(&f, &fi).unwrap();
assert!((volume(&back) - sharp).abs() < 1e-9 * sharp, "{}", volume(&back));
assert_eq!(back.face_count(), 6);
// A chamfer likewise.
let c = chamfer(&b, &[Vec3::new(20.0, 30.0, 20.0)], 4.0).unwrap();
let ci = faces_where(&c, |p| p.y > 26.0 - 1e-6 && p.z > 16.0 - 1e-6 && (p.y < 30.0 - 1e-9 || p.z < 20.0 - 1e-9));
let back = delete_faces(&c, &ci).unwrap();
assert!((volume(&back) - sharp).abs() < 1e-9 * sharp, "{}", volume(&back));
// A through hole: deleting its wall fills it.
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, 4.0, 12.0).unwrap();
let holed = boolean(&plate, &pin, BoolOp::Cut).unwrap().unwrap();
let wall = faces_where(&holed, |p| ((p.x - 20.0).hypot(p.y - 15.0) - 4.0).abs() < 1e-2);
assert!(!wall.is_empty());
let filled = delete_faces(&holed, &wall).unwrap();
assert!((volume(&filled) - 12000.0).abs() < 1e-9 * 12000.0, "{}", volume(&filled));
// A blind hole: its wall and floor.
let drill = cylinder(Vec3::new(20.0, 15.0, 4.0), Vec3::Z, 3.0, 7.0).unwrap();
let blind = boolean(&plate, &drill, BoolOp::Cut).unwrap().unwrap();
let hole = faces_where(&blind, |p| (p.x - 20.0).hypot(p.y - 15.0) < 3.0 + 1e-3 && p.z > 4.0 - 1e-6);
let filled = delete_faces(&blind, &hole).unwrap();
assert!((volume(&filled) - 12000.0).abs() < 1e-9 * 12000.0, "{}", volume(&filled));
// A boss on the plate: deleting its wall and top takes it off.
let boss = cylinder(Vec3::new(10.0, 10.0, 10.0), Vec3::Z, 3.0, 5.0).unwrap();
let bossed = boolean(&plate, &boss, BoolOp::Union).unwrap().unwrap();
let bf = faces_where(&bossed, |p| p.z > 10.0 - 1e-9 && (p.x - 10.0).hypot(p.y - 10.0) < 3.0 + 1e-6);
let off = delete_faces(&bossed, &bf).unwrap();
assert!((volume(&off) - 12000.0).abs() < 1e-9 * 12000.0, "{}", volume(&off));
// Errors name what is wrong.
assert!(delete_faces(&plate, &[99]).is_err());
assert!(delete_faces(&plate, &[0, 1, 2, 3, 4, 5]).is_err());
// One face of a box: the sides cannot close it.
let top = faces_where(&plate, |p| (p.z - 10.0).abs() < 1e-9);
assert!(delete_faces(&plate, &top).is_err());
}
}
+2
View File
@@ -17,6 +17,7 @@ mod body;
mod build;
mod coplanar;
mod curveblend;
mod delete_face;
mod freeform;
mod heal;
mod helix;
@@ -40,6 +41,7 @@ mod topo;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint};
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
pub use delete_face::delete_faces;
pub use helix::{loft_to_point, sweep_helix};
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
pub use iges_out::iges_export_bodies;