blend_corner.rs rebuilds the planar faces at the ends of the filleted edges (three planar faces and three straight edges at each such vertex) and keeps the rest. One round at a vertex ends in the third face's exact section of its cylinder; two rounds with the third edge sharp trim each other (rolling ball: the crease from where their contact lines cross to the third edge for equal radii, else to the smaller round's contact line on the face that ends the larger one, which gets the exact section between); three rounds meet a sphere for equal radii, else a rational degree-4 patch through plane sections of the cylinders, the sections turned so that the cylinders' curvatures agree where two meet on a face and the patch fitted tangent to all three (within 0.01 degrees) and to the face at its closed corner. kernel::fillet falls back to it when the local blends refuse: an edge that ends on an earlier round (any radius) takes the round off (delete_faces) and blends both edges together, so r3 next to r2 and a third edge into a mitred pair work. delete_faces: a gap stretch along several deleted faces keeps the corners between them (two rounds meeting in a mitre come off back to the sharp corner). splitface::fit is shared for the crease. Tests: volumes against the strips-minus-overlap reference (exact strips, overlap integrated; checked against an independent 1D integral), the sphere-octant formula for three equal rounds, tangency along the patch, closed meshes, refusals without panics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
363 lines
17 KiB
Rust
363 lines
17 KiB
Rust
//! 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();
|
|
// The moved corners along a gap stretch (two rounds meeting in a mitre leave
|
|
// the sharp corner between them).
|
|
let mut pending: Option<Vec<Vec3>> = None;
|
|
let close = |pts: Vec<Vec3>, a: Vec3| -> Vec<(Vec3, Vec3)> {
|
|
let mut c: Vec<Vec3> = Vec::new();
|
|
for p in pts.into_iter().chain(std::iter::once(a)) {
|
|
if c.last().is_some_and(|l| l.dist(p) <= q * 10.0) {
|
|
continue;
|
|
}
|
|
// Drop a corner in line with its neighbours.
|
|
if let [.., x, y] = c[..]
|
|
&& (y - x).cross(p - y).len() <= q * 10.0 * (p - x).len()
|
|
&& (y - x).dot(p - y) > 0.0
|
|
{
|
|
c.pop();
|
|
}
|
|
c.push(p);
|
|
}
|
|
c.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
|
|
};
|
|
for k in 0..m {
|
|
let Some(e) = edges.get((start + k) % m) else { continue };
|
|
if gap(e) {
|
|
pending.get_or_insert_with(Vec::new).extend([pos(e.front()), pos(e.back())]);
|
|
continue;
|
|
}
|
|
let (a, b) = (pos(e.front()), pos(e.back()));
|
|
if let Some(p) = pending.take() {
|
|
for (x, y) in close(p, a) {
|
|
new_edges.push(line(x, y, &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());
|
|
for (x, y) in close(p, a) {
|
|
new_edges.push(line(x, y, &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());
|
|
}
|
|
}
|