diff --git a/crates/kernel/src/delete_face.rs b/crates/kernel/src/delete_face.rs new file mode 100644 index 00000000..df81dc40 --- /dev/null +++ b/crates/kernel/src/delete_face.rs @@ -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) -> 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.require_brep("Delete Face")?; + let all: Vec = body.solid.face_iter().cloned().collect(); + let n = all.len(); + if faces.is_empty() { + return Err(fail("no faces given")); + } + let del: BTreeSet = 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> = HashMap::new(); + let mut vfaces: HashMap> = 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 = 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 = 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 = neighbours.iter().copied().collect(); + let mut corners: Vec = 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 = 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 = 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 = 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| -> 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 = 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 = Vec::new(); + for w in f.boundaries() { + let edges: Vec = 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 = Vec::new(); + let mut pending: Option = 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 { + 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()); + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 9892c2b7..293d418d 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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;