//! Convex polyhedra from planes, and the operations that use them on convex planar bodies: //! shell (hollow out with open faces) and draft (tilt faces about a neutral plane). The solid //! is built directly from its planes (vertices are triple-plane intersections), so it is exact //! and needs no booleans except the final shell subtraction. use std::collections::HashMap; use solidcraft_geom::{Plane, Vec3}; use truck_modeling::{self as mt, builder}; use crate::body::{Body, Solid, from_p3, p3, v3}; use crate::history::{EdgeOrigin, FaceOrigin, FaceProv, History}; use crate::record; use crate::{KernelError, Result, guard}; /// A half-space `n · x <= d` (n is the outward unit normal). #[derive(Clone, Copy, Debug)] pub struct HalfSpace { pub n: Vec3, pub d: f64, } fn det3(a: Vec3, b: Vec3, c: Vec3) -> f64 { a.dot(b.cross(c)) } /// Vertices, and per half-space with a face its vertex loop (counter-clockwise about the /// outward normal), of the convex polyhedron bounded by the half-spaces. fn combinatorics(hs: &[HalfSpace]) -> Result<(Vec, Vec<(usize, Vec)>)> { if hs.len() < 4 || hs.len() > 200 { return Err(KernelError::Invalid("a polyhedron needs 4…200 planes".into())); } let scale = hs.iter().map(|h| h.d.abs()).fold(1.0, f64::max); let eps = scale * 1e-9; // Vertices: intersections of three planes inside all others. let mut verts: Vec = Vec::new(); for i in 0..hs.len() { for j in (i + 1)..hs.len() { for k in (j + 1)..hs.len() { let (a, b, c) = (hs[i], hs[j], hs[k]); let den = det3(a.n, b.n, c.n); if den.abs() < 1e-12 { continue; } let x = (b.n.cross(c.n) * a.d + c.n.cross(a.n) * b.d + a.n.cross(b.n) * c.d) / den; if !x.is_finite() || hs.iter().any(|h| h.n.dot(x) > h.d + eps * 10.0) { continue; } if !verts.iter().any(|v| v.dist(x) < scale * 1e-8) { verts.push(x); } } } } if verts.len() < 4 { return Err(KernelError::Invalid("the planes don't enclose a solid".into())); } // Faces: the vertices on each plane, ordered counter-clockwise about its outward normal. let mut faces_idx: Vec<(usize, Vec)> = Vec::new(); for (hi, h) in hs.iter().enumerate() { let on: Vec = (0..verts.len()).filter(|i| verts.get(*i).is_some_and(|v| (h.n.dot(*v) - h.d).abs() < scale * 1e-7)).collect(); if on.len() < 3 { continue; } let c = on.iter().filter_map(|i| verts.get(*i)).fold(Vec3::ZERO, |a, v| a + *v) / on.len() as f64; let u = h.n.any_perp(); let w = h.n.cross(u); let mut ordered = on.clone(); ordered.sort_by(|a, b| { let ang = |i: usize| verts.get(i).map(|v| (*v - c).dot(w).atan2((*v - c).dot(u))).unwrap_or(0.0); ang(*a).total_cmp(&ang(*b)) }); faces_idx.push((hi, ordered)); } Ok((verts, faces_idx)) } /// The convex polyhedron bounded by the half-spaces. pub fn convex_polyhedron(hs: &[HalfSpace]) -> Result { polyhedron(hs).map(|(b, _)| b) } /// [`convex_polyhedron`], with the half-space each of its faces lies on (in face order). fn polyhedron(hs: &[HalfSpace]) -> Result<(Body, Vec)> { let (verts, faces_idx) = combinatorics(hs)?; let on: Vec = faces_idx.iter().map(|(hi, _)| *hi).collect(); guard("polyhedron", || { let tv: Vec = verts.iter().map(|v| builder::vertex(p3(*v))).collect(); let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new(); let mut faces: Vec = Vec::new(); for (_, f) in &faces_idx { let n = f.len(); let mut wire: Vec = Vec::with_capacity(n); for k in 0..n { let (Some(&a), Some(&b)) = (f.get(k), f.get((k + 1) % n)) else { continue }; let key = (a.min(b), a.max(b)); let e = match edges.get(&key) { Some(e) => e.clone(), None => { let (Some(va), Some(vb)) = (tv.get(key.0), tv.get(key.1)) else { continue }; let e = builder::line(va, vb); edges.insert(key, e.clone()); e } }; wire.push(if a < b { e } else { e.inverse() }); } let face = builder::try_attach_plane(&[wire.into()]).map_err(|e| KernelError::Failed(format!("polyhedron face: {e}")))?; faces.push(face); } let shell: mt::Shell = faces.into(); let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("polyhedron: {e}")))?; Ok((Body::new(solid)?, on)) }) } /// The half-spaces of a convex body with only planar faces, each with a point on it and the /// body's faces on its plane (in face order), or an error. pub fn planar_convex(b: &Body) -> Result)>> { b.require_brep("this operation")?; let tol = (b.size() * 1e-3).max(1e-3); let faces = b.faces(tol)?; let mut out: Vec<(HalfSpace, Vec3, Vec)> = Vec::new(); for f in &faces { let n = f.plane_normal.ok_or_else(|| KernelError::Failed("not supported yet: bodies with curved faces".into()))?; let d = n.dot(f.centroid); match out.iter_mut().find(|(h, ..)| h.n.dot(n) > 1.0 - 1e-9 && (h.d - d).abs() < tol) { Some((.., on)) => on.push(f.index), None => out.push((HalfSpace { n, d }, f.centroid, vec![f.index])), } } let verts: Vec = b.solid.vertex_iter().map(|v| from_p3(v.point())).collect(); for (h, ..) in &out { if verts.iter().any(|v| h.n.dot(*v) > h.d + tol) { return Err(KernelError::Failed("not supported yet: shell and draft of non-convex bodies".into())); } } Ok(out) } /// The faces of `b` on half-space `k`'s plane (`groups[k]`) as the face over them: moved, or /// merged when several. fn on_plane(under: &[usize]) -> FaceProv { record::region(under.iter().map(|&face| FaceOrigin::Face { operand: 0, face }).collect(), false) } /// The edges of `b` between faces on two of its planes (`groups[k]`: its faces on plane `k`), /// by pair of planes (the lower first). fn plane_edges(b: &Body, groups: &[Vec]) -> HashMap<(usize, usize), Vec> { let plane_of: HashMap = groups.iter().enumerate().flat_map(|(k, g)| g.iter().map(move |f| (*f, k))).collect(); let mut out: HashMap<(usize, usize), Vec> = HashMap::new(); for (edge, fs) in crate::history::edge_faces(&b.solid).iter().enumerate() { if let [p, q] = fs[..] && let (Some(&x), Some(&y)) = (plane_of.get(&p), plane_of.get(&q)) && x != y { out.entry((x.min(y), x.max(y))).or_default().push(edge); } } out } /// The history over `[b]` of a convex polyhedron `poly` whose face `j` lies on the plane of /// half-space `on[j]`, made from the faces `groups[k]` of `b` on half-space `k`'s plane: each /// face the faces under it, each edge along the edges of `b` between faces of its two planes, /// or new where they met nowhere. fn planes_history(poly: &Body, on: &[usize], groups: &[Vec], b: &Body) -> Option { let faces: Vec = on.iter().map(|k| groups.get(*k).map(|under| on_plane(under))).collect::>()?; let along = plane_edges(b, groups); let mut h = record::from_faces(&poly.solid, 1, faces); for e in &mut h.edges { if let [x, y] = e.faces[..] && let (Some(&kx), Some(&ky)) = (on.get(x), on.get(y)) && let Some(edges) = along.get(&(kx.min(ky), kx.max(ky))) { e.origins = edges.iter().map(|&edge| EdgeOrigin::Edge { operand: 0, edge }).collect(); } } Some(h) } /// The history over `[b]` of a cavity `poly` whose face `j` lies on half-space `on[j]`, moved /// in from the faces `groups[k]` of `b`: each face an offset of the faces it lines, each edge new. fn cavity_history(poly: &Body, on: &[usize], groups: &[Vec]) -> Option { let faces = on .iter() .map(|k| groups.get(*k).map(|under| record::generated(under.iter().map(|&face| FaceOrigin::Offset { operand: 0, face }).collect()))) .collect::>()?; Some(record::from_faces(&poly.solid, 1, faces)) } /// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed. The /// result's history is over `[b]`: the inner walls are offsets of the faces they line. pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result { guard("shell", || shell_impl(b, open, thickness)?.with_shell_planar_sources(b, thickness, open)) } fn shell_impl(b: &Body, open: &[Vec3], thickness: f64) -> Result { b.require_brep("shell")?; if !(thickness.is_finite() && thickness > 1e-6) { return Err(KernelError::Invalid("shell thickness must be positive".into())); } let hs = match planar_convex(b) { Ok(h) => h, Err(_) => return shell_planar(b, open, thickness), }; let size = b.size(); let inner: Vec = hs .iter() .map(|(h, c, _)| { let removed = open.iter().any(|p| (h.n.dot(*p) - h.d).abs() < size * 1e-4 + 1e-6 && p.dist(*c) < size * 2.0); if removed { HalfSpace { n: h.n, d: h.d + thickness + size * 0.05 } } else { HalfSpace { n: h.n, d: h.d - thickness } } }) .collect(); if !hs.iter().zip(&inner).any(|((h, ..), i)| i.d > h.d) { return Err(KernelError::Invalid("select at least one face to remove".into())); } let (cavity, on) = polyhedron(&inner)?; let groups: Vec> = hs.into_iter().map(|(.., under)| under).collect(); let walls = cavity_history(&cavity, &on, &groups); let r = crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the shell removed everything".into()))?; Ok(hollowed(r, b, walls)) } /// `r`, `b` less a cavity whose history over `[b]` is `walls`, with its history over `[b]`. fn hollowed(r: Body, b: &Body, walls: Option) -> Body { let h = crate::boolean_record::of_pair(&r, &crate::boolean_record::kept(b, 0, 1), &walls); r.with_checked_history(h, &record::counts(&[b])) } /// The history over `[b]` of a cavity `offset` made from `b` by moving its faces (whose history /// over `[b]` says so): each face an offset of the face it was moved from, each edge new. fn offsets(offset: &Body) -> Option { let h = offset.history()?; let faces: Vec = h .faces .iter() .map(|f| match f.origins.as_slice() { [FaceOrigin::Face { operand, face }] => Some(record::generated(vec![FaceOrigin::Offset { operand: *operand, face: *face }])), _ => None, }) .collect::>()?; Some(record::from_faces(&offset.solid, h.operands, faces)) } /// Tilt the faces near `faces` by `angle` about their line on the neutral plane, so that they /// lean in (positive angle) going along `pull`. pub fn draft(b: &Body, faces: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) -> Result { b.require_brep("draft")?; if !angle.is_finite() || angle.abs() >= std::f64::consts::FRAC_PI_2 - 1e-3 { return Err(KernelError::Invalid("draft angle must be between −90° and 90°".into())); } let pull = pull.normalized().ok_or_else(|| KernelError::Invalid("pull direction".into()))?; let hs = match planar_convex(b) { Ok(h) => h, Err(_) => return draft_walls(b, faces, neutral, pull, angle), }; let size = b.size(); let mut out = Vec::new(); let mut moved = 0; for (h, c, _) in &hs { let chosen = faces.iter().any(|p| (h.n.dot(*p) - h.d).abs() < size * 1e-4 + 1e-6 && p.dist(*c) < size * 2.0); if !chosen { out.push(*h); continue; } if h.n.dot(pull).abs() > 1e-6 { return Err(KernelError::Failed("not supported yet: drafting faces that aren't parallel to the pull direction".into())); } // Hinge: the face's line on the neutral plane; rotate the normal toward the pull. let n2 = (h.n * angle.cos() + pull * angle.sin()).normalized().ok_or_else(|| KernelError::Failed("draft".into()))?; let hinge_pt = neutral.intersect_ray(*c, pull).ok_or_else(|| KernelError::Failed("the neutral plane is parallel to the pull direction".into()))?; out.push(HalfSpace { n: n2, d: n2.dot(hinge_pt) }); moved += 1; } if moved == 0 { return Err(KernelError::Invalid("no faces to draft".into())); } // Each half-space in place of the one it was made from: its faces moved (or as they were). let (r, on) = polyhedron(&out)?; let groups: Vec> = hs.into_iter().map(|(.., under)| under).collect(); let h = planes_history(&r, &on, &groups, b); Ok(r.with_checked_history(h, &record::counts(&[b]))) } /// Draft of a non-convex body: walls (planes, cylinders) next to caps square to `pull`. fn draft_walls(b: &Body, at: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) -> Result { let healed = crate::boolean_record::healed(b)?; let size = healed.size(); let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; let mut chosen: Vec = Vec::new(); for p in at { let near = mesh .triangles .iter() .zip(&mesh.tri_face) .filter_map(|(t, f)| mesh.tri(t).map(|[x, y, z]| (point_tri(*p, x, y, z), *f as usize))) .min_by(|a, c| a.0.total_cmp(&c.0)); match near { Some((d, f)) if d < size * 1e-3 + 1e-6 => { if !chosen.contains(&f) { chosen.push(f); } } _ => return Err(KernelError::Invalid(format!("no face at {:?}", [p.x, p.y, p.z]))), } } // A round wall is stored in pieces (a cylinder's two halves): picking one drafts the whole wall. let chosen = crate::offset::with_same_surface(&healed, &chosen); Ok(record::after(crate::offset::draft_walls(&healed, &chosen, neutral, pull, angle)?, &healed, &record::counts(&[b]))) } /// Round every edge of a convex body with planar faces by `r`: faces shrink, each edge becomes a /// cylinder and each corner a sphere patch (the body is the inner polyhedron grown by a ball). /// The result's history is over `[b]`: each cylinder in place of the edges of `b` between its /// two planes' faces, each corner patch in place of those round its corner. pub fn round_all_edges(b: &Body, r: f64) -> Result { let planes = planar_convex(b)?; let hs: Vec = planes.iter().map(|(h, ..)| *h).collect(); let groups: Vec> = planes.iter().map(|(.., under)| under.clone()).collect(); // The edges of `b` between faces on planes f and g. let edges = plane_edges(b, &groups); let along = |f: usize, g: usize| -> Vec { edges.get(&(f.min(g), f.max(g))).into_iter().flatten().map(|&edge| FaceOrigin::Edge { operand: 0, edge }).collect() }; let inner: Vec = hs.iter().map(|h| HalfSpace { n: h.n, d: h.d - r }).collect(); let (q, faces) = combinatorics(&inner)?; if faces.len() != hs.len() { return Err(KernelError::Failed("not supported yet: the rounding is larger than a face".into())); } let normal = |hi: usize| hs.get(hi).map(|h| h.n).unwrap_or(Vec3::Z); // Edges of the inner polyhedron: (vertex a, vertex b) → (face left, face right). let mut edge_faces: HashMap<(usize, usize), Vec> = HashMap::new(); for (hi, lp) in &faces { let n = lp.len(); for k in 0..n { let (Some(&a), Some(&bb)) = (lp.get(k), lp.get((k + 1) % n)) else { continue }; edge_faces.entry((a.min(bb), a.max(bb))).or_default().push(*hi); } } if edge_faces.values().any(|f| f.len() != 2) { return Err(KernelError::Failed("not supported yet: rounding this shape".into())); } guard("round edges", || { // Vertex of face `hi` at inner vertex `qi`. let mut verts: HashMap<(usize, usize), mt::Vertex> = HashMap::new(); let mut vert = |hi: usize, qi: usize| -> mt::Vertex { verts.entry((hi, qi)).or_insert_with(|| builder::vertex(p3(q.get(qi).copied().unwrap_or_default() + normal(hi) * r))).clone() }; let mut lines: HashMap<(usize, usize, usize), mt::Edge> = HashMap::new(); let mut arcs: HashMap<(usize, usize, usize), mt::Edge> = HashMap::new(); // Straight edge of face hi between inner vertices a and b (stored low → high). let mut line = |hi: usize, a: usize, bb: usize, vert: &mut dyn FnMut(usize, usize) -> mt::Vertex| -> mt::Edge { let (lo, hi_v) = (a.min(bb), a.max(bb)); let e = lines.entry((hi, lo, hi_v)).or_insert_with(|| builder::line(&vert(hi, lo), &vert(hi, hi_v))).clone(); if a < bb { e } else { e.inverse() } }; // Arc at inner vertex qi from face f to face g (stored with f < g). let mut arc = |qi: usize, f: usize, g: usize, vert: &mut dyn FnMut(usize, usize) -> mt::Vertex| -> mt::Edge { let (lo, hi_f) = (f.min(g), f.max(g)); let c = q.get(qi).copied().unwrap_or_default(); let e = arcs .entry((qi, lo, hi_f)) .or_insert_with(|| { let mid = c + (normal(lo) + normal(hi_f)).normalized().unwrap_or(Vec3::Z) * r; builder::circle_arc(&vert(lo, qi), &vert(hi_f, qi), p3(mid)) }) .clone(); if f < g { e } else { e.inverse() } }; let mut out: Vec = Vec::new(); // What each face is made from (in `out`'s order). let mut made: Vec = Vec::new(); // Planar faces. for (hi, lp) in &faces { let n = lp.len(); let mut w: Vec = Vec::new(); for k in 0..n { let (Some(&a), Some(&bb)) = (lp.get(k), lp.get((k + 1) % n)) else { continue }; w.push(line(*hi, a, bb, &mut vert)); } out.push(builder::try_attach_plane(&[w.into()]).map_err(|e| KernelError::Failed(format!("rounded face: {e}")))?); made.push(on_plane(groups.get(*hi).map_or(&[], Vec::as_slice))); } use mt::{ParametricSurface3D, SearchNearestParameter}; let orient = |mut surf: mt::Surface, at: Vec3, outward: Vec3| -> mt::Surface { if let Some((u, v)) = surf.search_nearest_parameter(p3(at), None, 100) { let nn = surf.normal(u, v); if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 { surf = mt::Invertible::inverse(&surf); } } surf }; // Edge cylinders: face f's loop runs a → b, so the cylinder (to f's right) runs b → a // along f, then across to g. for ((a, bb), fs) in &edge_faces { let (Some(&f0), Some(&f1)) = (fs.first(), fs.get(1)) else { continue }; // Which face runs a → b? let runs = |hi: usize| { faces.iter().find(|(h, _)| *h == hi).is_some_and(|(_, lp)| { let n = lp.len(); (0..n).any(|k| lp.get(k) == Some(a) && lp.get((k + 1) % n) == Some(bb)) }) }; let (f, g) = if runs(f0) { (f0, f1) } else { (f1, f0) }; let w: Vec = vec![line(f, *bb, *a, &mut vert), arc(*a, f, g, &mut vert), line(g, *a, *bb, &mut vert), arc(*bb, g, f, &mut vert)]; let (qa, qb) = (q.get(*a).copied().unwrap_or_default(), q.get(*bb).copied().unwrap_or_default()); let profile = arc(*a, f, g, &mut vert); let swept = builder::tsweep(&profile, v3(qb - qa)); let out_dir = (normal(f) + normal(g)).normalized().unwrap_or(Vec3::Z); let mid = (qa + qb) * 0.5 + out_dir * r; let surf = orient(swept.oriented_surface(), mid, out_dir); out.push(mt::Face::try_new(vec![w.into()], surf).map_err(|e| KernelError::Failed(format!("rounded edge: {e}")))?); made.push(record::generated(along(f, g))); } // Corner sphere patches: the faces around each inner vertex, counter-clockwise. for (qi, c) in q.iter().enumerate() { let mut around: Vec = faces.iter().filter(|(_, lp)| lp.contains(&qi)).map(|(h, _)| *h).collect(); let axis = around.iter().fold(Vec3::ZERO, |acc, h| acc + normal(*h)).normalized().unwrap_or(Vec3::Z); let u = axis.any_perp(); let w2 = axis.cross(u); around.sort_by(|x, y| { let ang = |h: usize| normal(h).dot(w2).atan2(normal(h).dot(u)); ang(*x).total_cmp(&ang(*y)) }); let k = around.len(); let w: Vec = (0..k).filter_map(|i| Some(arc(qi, *around.get(i)?, *around.get((i + 1) % k)?, &mut vert))).collect(); // A sphere surface whose poles and seam are away from this patch. let side = axis.any_perp(); let (np, sp, back) = (*c + side * r, *c - side * r, *c - axis * r); let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back)); let surf = mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(*c), v3(side)))); let surf = orient(surf, *c + axis * r, axis); out.push(mt::Face::try_new(vec![w.into()], surf).map_err(|e| KernelError::Failed(format!("rounded corner: {e}")))?); let mut corner: Vec = (0..k).filter_map(|i| Some((*around.get(i)?, *around.get((i + 1) % k)?))).flat_map(|(f, g)| along(f, g)).collect(); corner.sort(); corner.dedup(); made.push(record::generated(corner)); } let shell: mt::Shell = out.into(); let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("rounded body: {e}")))?; let body = Body::new(solid)?; // Every face from something of `b` (a blend between planes that met nowhere has none). let history = made.iter().all(|f| !f.origins.is_empty()).then(|| record::from_faces(&body.solid, 1, made)); Ok(body.with_checked_history(history, &record::counts(&[b]))) }) } /// Shell of a body whose faces are all planar (convex or not): every face plane moves inward /// by the thickness (open faces outward, past the body) keeping the body's topology, which /// gives the cavity; the shell is the body minus the cavity. fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result { // Face paint and authored splits are construction boundaries. Retain their // eligibility veto through the temporary body used for the cavity Cut. // Paint indices stay in source order by avoiding healing in that case. let healed = if b.paint().is_some_and(|paint| !paint.faces.is_empty()) { crate::boolean_record::copied(b)? } else { crate::boolean_record::healed(b)? }; let healed = healed.with_paint(b.paint().cloned()).with_split_lines(b.split_lines().cloned()).inherit_regularization_protection(b); let size = healed.size(); let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; // Faces to open: the face of the triangle nearest each point. let mut opened: Vec = Vec::new(); for p in open { let near = mesh .triangles .iter() .zip(&mesh.tri_face) .filter_map(|(t, f)| mesh.tri(t).map(|[x, y, z]| (point_tri(*p, x, y, z), *f as usize))) .min_by(|a, c| a.0.total_cmp(&c.0)); if let Some((d, f)) = near && d < size * 1e-3 + 1e-6 && !opened.contains(&f) { opened.push(f); } } if opened.is_empty() { return Err(KernelError::Invalid("select at least one face to remove".into())); } // A successful Boolean is not evidence that a tangential rim has wall thickness. // Refuse only when the entire planar opening loses its rim. A single collapsed // round can still end in a valid sharp edge (the tutorial plate, for example). if let Some(r) = rim_without_thickness(&healed, &opened, thickness, true) { return Err(unsupported_shell(&format!( "the rim would have no thickness: an open face meets a round of radius {r:.4} mm tangentially, and the shell is {thickness:.4} mm thick (make the round smaller than the thickness)" ))); } let margin = thickness + size * 0.05; shell_opened(b, &healed, open, &opened, thickness, margin).map_err(|e| match rim_without_thickness(&healed, &opened, thickness, false) { Some(r) => unsupported_shell(&format!( "the rim would have no thickness: an open face meets a round of radius {r:.4} mm tangentially, and the shell is {thickness:.4} mm thick (make the round smaller than the thickness)" )), None => e, }) } /// The radius of a convex round (cylinder or torus) that meets an open face tangentially and /// is at least `thickness`: the cavity's wall then passes through the rim where the round meets /// the open face, leaving nothing between them. fn rim_without_thickness(b: &Body, opened: &[usize], thickness: f64, whole_rim: bool) -> Option { use mt::{BoundedCurve, ParametricCurve}; let faces: Vec<&mt::Face> = b.solid.face_iter().take(if whole_rim { 4097 } else { usize::MAX }).collect(); let tol = (b.size() * 1e-7).max(1e-9) * 100.0; // A distant shell can make the body's tolerance larger than this wall. // Such a diagnostic candidate is not sufficient to refuse a valid opening. if whole_rim && tol > thickness * 1e-3 { return None; } opened.iter().filter_map(|fi| faces.get(*fi)).find_map(|open| { let plane = crate::offset::surf_of(open, tol)?; if whole_rim && (!matches!(plane, crate::offset::Surf::Plane { .. }) || faces.len() > 4096 || open.edge_iter().take(1025).count() > 1024) { return None; } let radius = |edge: &mt::Edge| { faces.iter().filter(|g| !std::ptr::eq(**g, *open)).find_map(|g| { if !g.edge_iter().any(|e| e.id() == edge.id()) { return None; } let round = crate::offset::surf_of(g, tol)?; let r = match round { crate::offset::Surf::Cylinder { r, convex: true, .. } | crate::offset::Surf::Torus { r, convex: true, .. } => r, _ => return None, }; let c = edge.curve(); let (t0, t1) = c.range_tuple(); if !t0.is_finite() || !t1.is_finite() || t0 >= t1 { return None; } let parameter = t0 * 0.5 + t1 * 0.5; if !parameter.is_finite() || parameter < t0 || parameter > t1 { return None; } let mid = from_p3(c.subs(parameter)); let tangent = plane.normal(mid)?.dot(round.normal(mid)?) > 1.0 - 1e-6; (tangent && r >= thickness - tol).then_some(r) }) }; if whole_rim { let mut found = None; for edge in open.edge_iter() { found = Some(radius(&edge)?); } found } else { open.edge_iter().find_map(|edge| radius(&edge)) } }) } fn shell_opened(b: &Body, healed: &Body, open: &[Vec3], opened: &[usize], thickness: f64, margin: f64) -> Result { let size = healed.size(); // Open faces pushed out past the body: the simple way, unless a face tangent to an open // one (a fillet along its edge) can't follow; then every face moves in (tangencies keep) // and the opening is the open face's copy swept out through the body. let cavity = match offset_planar(healed, |fi, _| if opened.contains(&fi) { margin } else { -thickness }) { Ok(c) => c, Err(e) => return shell_by_opening(healed, opened, thickness, margin).map_err(|_| e), }; let r = crate::ops::boolean(healed, &cavity, crate::BoolOp::Cut); if r.is_err() && let Ok(x) = shell_by_opening(healed, opened, thickness, margin) { return Ok(x); } match r { Ok(Some(r)) => { // Over `[healed]`, the cavity's faces offsets of the faces they line; then over `[b]`. Ok(record::after(hollowed(r, healed, offsets(&cavity)), healed, &record::counts(&[b]))) } Ok(None) => Err(KernelError::Failed("the shell removed everything".into())), // Healing can leave faces the boolean dislikes (revolved patches): the body as it was, // its faces to open found again. Err(e) => { let raw = b.tessellate((size * 1e-3).max(1e-3)).map_err(|_| e.clone())?; let mut opened_raw: Vec = Vec::new(); for p in open { let near = raw .triangles .iter() .zip(&raw.tri_face) .filter_map(|(t, f)| raw.tri(t).map(|[x, y, z]| (point_tri(*p, x, y, z), *f as usize))) .min_by(|a, c| a.0.total_cmp(&c.0)); if let Some((d, f)) = near && d < size * 1e-3 + 1e-6 && !opened_raw.contains(&f) { opened_raw.push(f); } } let opened = opened_raw; let cavity = offset_planar(b, |fi, _| if opened.contains(&fi) { margin } else { -thickness }).map_err(|_| e.clone())?; let r = crate::ops::boolean(b, &cavity, crate::BoolOp::Cut) .map_err(|_| e)? .ok_or_else(|| KernelError::Failed("the shell removed everything".into()))?; Ok(hollowed(r, b, offsets(&cavity))) } } } /// A shell made from the cavity with every face moved in by the thickness (open faces too), /// joined with each open face's moved copy swept out through the body. No history: the /// opening's walls, swept from the cavity's edges, come from nothing of `b`. fn shell_by_opening(b: &Body, opened: &[usize], thickness: f64, reach: f64) -> Result { let closed = offset_planar(b, |_, _| -thickness)?; let faces: Vec = closed.solid.face_iter().cloned().collect(); let mut cavity = closed.clone(); for fi in opened { let f = faces.get(*fi).ok_or_else(|| KernelError::Failed("shell: open face".into()))?; let mt::Surface::Plane(pl) = f.oriented_surface() else { return Err(unsupported_shell("the open face must be planar here")); }; let n = pl.normal(); let n = Vec3::new(n.x, n.y, n.z).normalized().ok_or_else(|| KernelError::Failed("shell: open face normal".into()))?; let prism = guard("shell opening", || { let solid: Solid = builder::tsweep(f, v3(n * (thickness + reach))); Ok(solid) })?; let prism = Body::new(prism)?; cavity = crate::ops::boolean(&cavity, &prism, crate::BoolOp::Union)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?; } let r = crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the shell removed everything".into()))?; Ok(r.without_history()) } fn unsupported_shell(m: &str) -> KernelError { KernelError::Failed(format!("not supported yet: {m}")) } fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { let n = (b - a).cross(c - a); let Some(nn) = n.normalized() else { return f64::INFINITY }; let h = (p - a).dot(nn); let q = p - nn * h; let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-12); if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) } } /// The body with every planar face moved along its outward normal by `shift(face index, /// normal)`, keeping the topology: each vertex goes to where its faces' moved planes meet. pub(crate) fn offset_planar(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Result { crate::offset::offset_body(b, shift) } pub(crate) fn solve3_pub(m: [[f64; 3]; 3], r: [f64; 3]) -> Option<[f64; 3]> { solve3(m, r) } fn solve3(m: [[f64; 3]; 3], r: [f64; 3]) -> Option<[f64; 3]> { let det = |a: [[f64; 3]; 3]| { a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]) }; let d = det(m); if d.abs() < 1e-9 { return None; } let mut out = [0.0; 3]; for (k, o) in out.iter_mut().enumerate() { let mut mk = m; for (row, rv) in mk.iter_mut().zip(r) { row[k] = rv; } *o = det(mk) / d; } Some(out) } /// Move the planar faces at the given points along their outward normals by `distance` /// (negative: into the body). The body's other faces follow (its topology is kept). pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result { b.require_brep("offset faces")?; if !distance.is_finite() || distance.abs() > 1e6 { return Err(KernelError::Invalid("offset distance".into())); } let healed = crate::boolean_record::healed(b)?.with_split_lines(b.splits.clone()); let size = healed.size(); let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; let mut chosen: Vec = Vec::new(); for p in at { let near = mesh .triangles .iter() .zip(&mesh.tri_face) .filter_map(|(t, f)| mesh.tri(t).map(|[x, y, z]| (point_tri(*p, x, y, z), *f as usize))) .min_by(|a, c| a.0.total_cmp(&c.0)); match near { Some((d, f)) if d < size * 1e-3 + 1e-6 => { if !chosen.contains(&f) { chosen.push(f); } } _ => return Err(KernelError::Invalid(format!("no face at {:?}", [p.x, p.y, p.z]))), } } let chosen = crate::offset::with_same_surface(&healed, &chosen); match offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }) { Ok(r) => Ok(record::after(r, &healed, &record::counts(&[b]))), // A piece of a split face (its neighbour stays): the piece swept along its normal // joins on (outward) or cuts in, with walls along the split lines. Err(e) if !healed.split_keep().is_empty() => press_pieces(&healed, &chosen, distance).map_err(|_| e), Err(e) => Err(e), } } /// No history: the walls along the split lines are swept from edges, and come from nothing of /// `b`. fn press_pieces(b: &Body, chosen: &[usize], distance: f64) -> Result { let faces: Vec = b.solid.face_iter().cloned().collect(); let mut cur = b.clone(); for fi in chosen { let f = faces.get(*fi).ok_or_else(|| KernelError::Failed("offset: face".into()))?; let n = crate::blend::plane_normal(f).ok_or_else(|| KernelError::Failed("offset: only planar pieces".into()))?; let tool = guard("offset face", || Body::new(builder::tsweep(f, crate::body::v3(n * distance))))?; let op = if distance > 0.0 { crate::BoolOp::Union } else { crate::BoolOp::Cut }; cur = crate::ops::boolean(&cur, &tool, op)?.ok_or_else(|| KernelError::Failed("offset: the piece moved off the body".into()))?; } Ok(cur.with_split_lines(b.splits.clone()).without_history()) } #[cfg(test)] mod rim_preflight_tests { use super::*; #[test] fn a_distant_shell_does_not_turn_a_smaller_local_round_into_a_zero_rim() { let block = crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap(); let edges = [Vec3::new(20.0, 0.0, 0.0), Vec3::new(40.0, 15.0, 0.0), Vec3::new(20.0, 30.0, 0.0), Vec3::new(0.0, 15.0, 0.0)]; let rounded = crate::fillet(&block, &edges, 1.5).unwrap(); let distant = crate::box_solid(Vec3::new(1_000_000.0, 0.0, 0.0), Vec3::new(1_000_010.0, 10.0, 10.0)).unwrap(); let combined = crate::boolean(&rounded, &distant, crate::BoolOp::Union).unwrap().unwrap(); let faces: Vec<_> = (0..combined.solid.face_iter().count()).collect(); assert!(rim_without_thickness(&combined, &faces, 2.0, true).is_none()); } }