From a5634078ef1a34caa3353a06ae97e2ca545e2d17 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:36:18 -0400 Subject: [PATCH] kernel: convex polyhedra, shell, draft and offset faces on SMLib only convex_polyhedron builds its schema 4 record straight from the planes (corners at triple-plane intersections, line edges, planar faces with exact line pcurves, canonical order) and SMLib takes it (Brep::from_record, trusted) and checks it. shell, draft and offset_faces are SMLib's local operations (smlib_shell, smlib_draft, smlib_offset_faces through offset_backend). Deleted: the half-space shell and draft of convex bodies, the planar-offset cavity shells (barrel, opening overreach, opening sweep), the non-convex draft walls, the split-piece press after a failed offset, round_all_edges and the helpers only they used, and the test of the deleted rim pre-check (rim_preflight_tests). (not built: base in progress) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt --- crates/kernel/src/polyhedron.rs | 937 +++++------------------------- crates/kernel/src/smlib_sweeps.rs | 21 +- 2 files changed, 150 insertions(+), 808 deletions(-) diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index 586010d421..603f1ca017 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -1,17 +1,16 @@ -//! 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. +//! Convex polyhedra from planes, and the local operations on a body's faces: shell (hollow out +//! with open faces), draft (tilt faces about a neutral plane) and offset faces, which are SMLib's +//! ([`crate::offset_backend`]). A polyhedron is its schema 4 record built directly from its +//! planes (vertices are triple-plane intersections, edges lines, faces planes, pcurves exact), +//! taken by SMLib and checked there. use std::collections::HashMap; -use solidcraft_geom::{Plane, Vec3}; -use truck_modeling::{self as mt, builder}; +use solidcraft_geom::Vec3; +use solidcraft_geom::brep::{self as rec, Curve2, EdgeUseRef, Loop, OrientedEdge, OrientedFace}; -use crate::body::{Body, Solid, from_p3, p3, v3}; -use crate::history::{Change, EdgeOrigin, FaceOrigin, FaceProv, History}; -use crate::record; -use crate::{KernelError, Result, guard}; +use crate::body::Body; +use crate::{KernelError, Result}; /// A half-space `n · x <= d` (n is the outward unit normal). #[derive(Clone, Copy, Debug)] @@ -77,746 +76,133 @@ fn combinatorics(hs: &[HalfSpace]) -> Result<(Vec, Vec<(usize, Vec) /// The convex polyhedron bounded by the half-spaces. pub fn convex_polyhedron(hs: &[HalfSpace]) -> Result { - polyhedron(hs).map(|(b, _)| b) + let (verts, faces) = combinatorics(hs)?; + crate::smlib_sweeps::solid_from_record(&polyhedron_record(hs, &verts, &faces)?, "polyhedron") } -/// [`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 +/// The record of the closed polyhedron with corners `verts` whose faces are `faces` (the +/// half-space each lies on, and its corners counter-clockwise about that half-space's outward +/// normal): each face on its plane with the outward normal as the natural one, each edge a line +/// used by its two faces, everything numbered in canonical order (faces as given, edges and +/// vertices as the faces first meet them). +fn polyhedron_record(hs: &[HalfSpace], verts: &[Vec3], faces: &[(usize, Vec)]) -> Result { + let bad = || KernelError::Failed("polyhedron: the planes do not close a solid".into()); + let p = |i: usize| verts.get(i).map(|v| [v.x, v.y, v.z]).ok_or_else(bad); + let scale = verts.iter().map(|v| v.len()).fold(1.0, f64::max); + let tolerance = (scale * 1e-9).max(1e-7); + // Each edge by its corners as first met, with its uses in walking order. + let mut edge_of: HashMap<(usize, usize), u32> = HashMap::new(); + let mut ends: Vec<(usize, usize)> = Vec::new(); + let mut uses: Vec> = Vec::new(); + let mut out = Vec::with_capacity(faces.len()); + for (fi, (hi, cycle)) in faces.iter().enumerate() { + let n = hs.get(*hi).and_then(|h| h.n.normalized()).ok_or_else(bad)?; + let first = cycle.first().ok_or_else(bad)?; + let o = verts.get(*first).copied().ok_or_else(bad)?; + let (u, v) = (n.any_perp(), n.cross(n.any_perp())); + let uv = |q: [f64; 3]| { + let d = Vec3::new(q[0], q[1], q[2]) - o; + [d.dot(u), d.dot(v)] + }; + let (mut lo, mut hi_uv) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + let mut edges = Vec::with_capacity(cycle.len()); + for k in 0..cycle.len() { + let (Some(&a), Some(&b)) = (cycle.get(k), cycle.get((k + 1) % cycle.len())) else { return Err(bad()) }; + let (e, forward) = match edge_of.get(&(b, a)) { + Some(&e) => (e, false), + None => { + if edge_of.contains_key(&(a, b)) { + return Err(bad()); } - }; - wire.push(if a < b { e } else { e.inverse() }); + let e = u32::try_from(ends.len()).map_err(|_| bad())?; + edge_of.insert((a, b), e); + ends.push((a, b)); + uses.push(Vec::new()); + (e, true) + } + }; + let face = u32::try_from(fi).map_err(|_| bad())?; + let position = u32::try_from(k).map_err(|_| bad())?; + uses.get_mut(e as usize).ok_or_else(bad)?.push(EdgeUseRef { face, r#loop: 0, position, upper: !forward }); + let &(s, t) = ends.get(e as usize).ok_or_else(bad)?; + let (ps, pt) = (uv(p(s)?), uv(p(t)?)); + for q in [ps, pt] { + for i in 0..2 { + lo[i] = lo[i].min(q[i]); + hi_uv[i] = hi_uv[i].max(q[i]); + } } - 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])), + // The line in the plane's parameters, on the edge's interval in its direction. + edges.push(OrientedEdge { edge: e, forward, pcurve: Curve2::Spline { knots: vec![0.0, 0.0, 1.0, 1.0], points: vec![ps, pt] } }); } + let w = |x: Vec3| [x.x, x.y, x.z]; + out.push(rec::Face { + surface: rec::Surface::Plane { origin: w(o), u_axis: w(u), v_axis: w(v) }, + domain: [[lo[0], hi_uv[0]], [lo[1], hi_uv[1]]], + loops: vec![Loop::Edges { edges }], + }); } - 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())); + // Vertices as the edges first meet them. + let mut vertex_of: HashMap = HashMap::new(); + let mut vertices: Vec = Vec::new(); + let mut edges = Vec::with_capacity(ends.len()); + for ((s, t), uses) in ends.iter().copied().zip(uses) { + if uses.len() != 2 { + return Err(bad()); } + let mut number = |c: usize| -> Result { + if let Some(&i) = vertex_of.get(&c) { + return Ok(i); + } + let i = u32::try_from(vertices.len()).map_err(|_| bad())?; + vertices.push(rec::Vertex { point: p(c)?, tolerance }); + vertex_of.insert(c, i); + Ok(i) + }; + let vs = [number(s)?, number(t)?]; + edges.push(rec::Edge { curve: rec::Curve::Line { start: p(s)?, end: p(t)? }, interval: [0.0, 1.0], vertices: vs, tolerance, uses }); } - Ok(out) + let count = u32::try_from(out.len()).map_err(|_| bad())?; + let brep = rec::Brep { + version: rec::VERSION, + tolerance, + regions: vec![rec::Region { shells: vec![0], void: false }, rec::Region { shells: vec![0], void: false }], + shells: vec![rec::Shell::Faces { faces: (0..count).map(|face| OrientedFace { face, forward: true }).collect(), wires: vec![] }], + faces: out, + edges, + vertices, + }; + brep.validate().map_err(|e| KernelError::Failed(format!("polyhedron: {e}")))?; + Ok(brep) } -/// 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. +/// Hollow a body: walls of `thickness` inside, open at the faces under `open` (SMLib's shell). +/// 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 { #[cfg(feature = "smlib")] - if crate::backend()? == crate::Backend::Smlib { - return crate::offset_backend::shell(b, open, thickness); + { + crate::offset_backend::shell(b, open, thickness) } - 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())); + #[cfg(not(feature = "smlib"))] + { + let _ = (b, open, thickness); + Err(crate::smlib_sweeps::not_built()) } - 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 { +/// lean in (positive angle) going along `pull` (SMLib's draft). +pub fn draft(b: &Body, faces: &[Vec3], neutral: &solidcraft_geom::Plane, pull: Vec3, angle: f64) -> Result { b.require_brep("draft")?; #[cfg(feature = "smlib")] - if crate::backend()? == crate::Backend::Smlib { - return crate::offset_backend::draft(b, faces, neutral, pull, angle); - } - 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.mesh(&crate::MeshTolerance::chord((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.mesh(&crate::MeshTolerance::chord((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)" - ))); - } - if let Some(shell) = shell_barrel(&healed, &opened, thickness)? { - return Ok(record::after(shell, &healed, &record::counts(&[b]))); - } - 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, - }) -} - -/// A convex circular-meridian barrel between two axial caps. Sew its exact inset walls -/// to the original walls at annular rims: a Boolean of the two spindle sheets cannot -/// reliably find their cap intersections, and sweeping an inset cap makes a cylindrical -/// lip that cuts away part of the curved wall. -fn shell_barrel(b: &Body, opened: &[usize], thickness: f64) -> Result> { - use crate::offset::Surf; - if b.solid.boundaries().len() != 1 || b.face_count() > 256 { - return Ok(None); - } - let faces: Vec = b.solid.face_iter().cloned().collect(); - let tol = (b.size() * 1e-7).max(1e-9) * 100.0; - let surfs: Option> = faces.iter().map(|f| crate::offset::surf_of(f, tol)).collect(); - let Some(surfs) = surfs else { return Ok(None) }; - let Some((centre, axis, big, radius)) = surfs.iter().find_map(|s| match *s { - Surf::Torus { o, a, big, h, r, convex: true } if big < -tol => Some((o + a * h, a, big, r)), - _ => None, - }) else { - return Ok(None); - }; - let mut caps = Vec::new(); - for (i, s) in surfs.iter().enumerate() { - match *s { - Surf::Torus { o, a, big: ring, h, r, convex: true } - if (o + a * h).dist(centre) < tol && a.dot(axis).abs() > 1.0 - 1e-9 && (ring - big).abs() < tol && (r - radius).abs() < tol => {} - Surf::Plane { n, d } if n.dot(axis).abs() > 1.0 - 1e-9 && faces.get(i).is_some_and(|f| f.boundaries().len() == 1) => { - caps.push((i, (d - n.dot(centre)) / n.dot(axis), n.dot(axis))); - } - _ => return Ok(None), - } - } - if caps.len() != 2 || opened.iter().any(|i| !caps.iter().any(|c| c.0 == *i)) { - return Ok(None); - } - caps.sort_by(|a, b| a.1.total_cmp(&b.1)); - let (Some(&(lower, lo, down)), Some(&(upper, hi, up))) = (caps.first(), caps.get(1)) else { return Ok(None) }; - if down >= 0.0 || up <= 0.0 { - return Ok(None); - } - let lo = lo + if opened.contains(&lower) { 0.0 } else { thickness }; - let hi = hi - if opened.contains(&upper) { 0.0 } else { thickness }; - let inner_radius = radius - thickness; - if lo >= hi || inner_radius <= 0.0 || [lo, hi].iter().any(|z| inner_radius * inner_radius - z * z <= big * big) { - return Err(KernelError::Invalid("shell thickness leaves no barrel cavity".into())); - } - let cavity = offset_planar(b, |i, _| if opened.contains(&i) { 0.0 } else { -thickness })?; - let history = cavity.history().ok_or_else(|| unsupported_shell("the barrel cavity has no face history"))?; - // Every wall must still be the one offset of its source, in source order. No collapsed - // faces or changed topology can be sewn by this construction. - if cavity.face_count() != faces.len() - || history.faces.iter().enumerate().any(|(i, f)| f.origins.as_slice() != [FaceOrigin::Face { operand: 0, face: i }]) { - return Err(unsupported_shell("the barrel cavity changed topology")); + crate::offset_backend::draft(b, faces, neutral, pull, angle) } - let inner: Vec = cavity.solid.face_iter().cloned().collect(); - let mut out = Vec::new(); - let mut provenance = Vec::new(); - for (i, outer) in faces.iter().enumerate() { - let inset = inner.get(i).ok_or_else(|| unsupported_shell("the barrel cavity lost a face"))?; - if opened.contains(&i) { - let mut wires = outer.absolute_boundaries().to_vec(); - wires.extend(inset.absolute_boundaries().iter().map(|w| w.inverse())); - let mut rim = mt::Face::try_new(wires, outer.surface()).map_err(|e| KernelError::Failed(format!("shell rim: {e}")))?; - if !outer.orientation() { - rim.invert(); - } - out.push(rim); - provenance.push(record::from_face(0, i, Change::Modified)); - } else { - out.extend([outer.clone(), inset.inverse()]); - provenance.extend([record::from_face(0, i, Change::Kept), record::generated(vec![FaceOrigin::Offset { operand: 0, face: i }])]); - } - } - let solid = Solid::try_new(vec![out.into()]).map_err(|e| KernelError::Failed(format!("shell barrel: {e}")))?; - let copies = record::edges_by_identity(&cavity, history).filter_map(|(id, origins)| origins.into_iter().next().map(|o| (id, o))).collect(); - record::attach(Body::new(solid)?, &[record::Operand::new(b)], Some(provenance), copies).map(Some) -} - -/// 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 cavity = match opening_overreach(healed, &cavity, opened, thickness, margin) { - Ok(Some(trimmed)) => trimmed, - Ok(None) => cavity, - Err(e) => { - log::warn!("shell: could not trim the opening to the open faces ({e}); the wall next to it may be cut"); - cavity - } - }; - let r = crate::ops::boolean(healed, &cavity, crate::BoolOp::Cut); - if r.is_err() - && let Ok(x) = shell_by_opening(healed, opened, thickness, margin) + #[cfg(not(feature = "smlib"))] { - return Ok(x); + let _ = (faces, neutral, pull, angle); + Err(crate::smlib_sweeps::not_built()) } - 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.mesh(&crate::MeshTolerance::chord((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))) - } - } -} - -/// The cavity with each open face pushed out (`cavity`), trimmed so that beyond an open face's -/// plane it only reaches through the open face itself; `None` when it already does. -/// -/// The pushed-out face carries the cavity past every edge of the open face. Past a convex edge -/// that is outside the body, but past a concave one (a box on a box, its side face open where it -/// meets the lower box's top) it runs on into the neighbouring wall and cuts a strip through it, -/// as wide as the push. The trimmed cavity is the shell's cavity with every face moved in, -/// together with the pushed-out cavity's parts that are on the body's side of every open face's -/// plane or in front of that open face. -fn opening_overreach(healed: &Body, cavity: &Body, opened: &[usize], thickness: f64, margin: f64) -> Result> { - let size = healed.size(); - let tol = size * 1e-6 + 1e-9; - let faces: Vec = healed.solid.face_iter().cloned().collect(); - // Each open face's plane (outward normal, offset) and its triangles. - let mesh = healed.mesh(&crate::MeshTolerance::chord((size * 1e-3).max(1e-3)))?; - let mut open: Vec<(&mt::Face, Vec3, f64, Vec<[Vec3; 3]>)> = Vec::new(); - for fi in opened { - let Some(f) = faces.get(*fi) else { return Ok(None) }; - let mt::Surface::Plane(pl) = f.oriented_surface() else { return Ok(None) }; - let n = pl.normal(); - let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() else { return Ok(None) }; - let d = n.dot(from_p3(pl.origin())); - let tris: Vec<[Vec3; 3]> = - mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, f)| **f as usize == *fi).filter_map(|(t, _)| mesh.tri(t)).collect(); - if tris.is_empty() { - return Ok(None); - } - open.push((f, n, d, tris)); - } - // Only a corner of the cavity past an open face's plane and not in front of that face can be - // in a wall; with none, the cavity is right as it is. - let overreach = cavity.solid.vertex_iter().any(|v| { - let q = from_p3(v.point()); - open.iter().any(|(_, n, d, tris)| { - let h = n.dot(q) - d; - h > tol && { - let on = q - *n * h; - !tris.iter().any(|[a, b, c]| point_tri(on, *a, *b, *c) < size * 1e-4 + 1e-6) - } - }) - }); - if !overreach { - return Ok(None); - } - let closed = offset_planar(healed, |_, _| -thickness)?; - // Keep: on the body's side of each open plane, or in front of the open face. - let reach = size * 4.0; - let mut keep: Option = None; - for (f, n, d, _) in &open { - let c = *n * *d; - let mut hs = vec![HalfSpace { n: *n, d: *d }]; - for axis in [Vec3::X, Vec3::Y, Vec3::Z] { - hs.push(HalfSpace { n: axis, d: axis.dot(c) + reach }); - hs.push(HalfSpace { n: axis * -1.0, d: -axis.dot(c) + reach }); - } - let (side, _) = polyhedron(&hs)?; - // In front of the face: its prism, from half a wall inside the body (overlapping `side` - // rather than touching it) to past the pushed-out face. - let inset = thickness * 0.5; - let prism = guard("shell opening prism", || { - let start = builder::translated(*f, v3(*n * -inset)); - let solid: Solid = builder::tsweep(&start, v3(*n * (inset + margin * 2.0))); - Ok(solid) - })?; - let prism = Body::new(prism)?; - let k = crate::ops::boolean(&side, &prism, crate::BoolOp::Union)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?; - keep = Some(match keep { - None => k, - Some(acc) => crate::ops::boolean(&acc, &k, crate::BoolOp::Intersect)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?, - }); - } - let Some(keep) = keep else { return Ok(None) }; - let kept = crate::ops::boolean(cavity, &keep, crate::BoolOp::Intersect)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?; - let trimmed = crate::ops::boolean(&closed, &kept, crate::BoolOp::Union)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?; - Ok(Some(trimmed)) -} - -/// 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}")) -} - -pub(crate) 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]> { @@ -843,21 +229,23 @@ fn solve3(m: [[f64; 3]; 3], r: [f64; 3]) -> Option<[f64; 3]> { Some(out) } -/// The faces of `body` the given points pick, each whole (every piece of its surface). -/// -/// Taken per body rather than once, because an operation may be tried again on the same body -/// with a face split in two, and the pieces are numbered differently then. The points do not -/// move, so they pick the same surface either way. -fn faces_at(body: &Body, at: &[Vec3]) -> Result> { - let size = body.size(); - let mesh = body.mesh(&crate::MeshTolerance::chord((size * 1e-3).max(1e-3)))?; +/// Move the faces at the given points along their outward normals by `distance` (negative: into +/// the body), SMLib's offset of faces: the faces around them are extended or trimmed to meet them. +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.mesh(&crate::MeshTolerance::chord((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))) + .filter_map(|(t, f)| mesh.tri(t).map(|[x, y, z]| (crate::boolean_guard::point_triangle_dist(*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 => { @@ -868,74 +256,15 @@ fn faces_at(body: &Body, at: &[Vec3]) -> Result> { _ => return Err(KernelError::Invalid(format!("no face at {:?}", [p.x, p.y, p.z]))), } } - Ok(crate::offset::with_same_surface(body, &chosen)) -} - -/// 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 chosen = crate::offset::with_same_surface(&healed, &chosen); + #[cfg(feature = "smlib")] + { + let moved = crate::offset_backend::offset_faces(&healed, &chosen, distance)?; + Ok(crate::record::after(moved, &healed, &crate::record::counts(&[b]))) } - // Healing is part of the attempt, not a step before it: a face that runs along a seam edge - // twice fails there, and the retry below needs the whole of it to run on the split body. - let run = |body: &Body| -> Result { - let healed = crate::boolean_record::healed(body)?.with_split_lines(body.splits.clone()); - let chosen = faces_at(&healed, at)?; - let moved = match crate::backend()? { - #[cfg(feature = "smlib")] - crate::Backend::Smlib => crate::offset_backend::offset_faces(&healed, &chosen, distance), - _ => offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }), - }; - match moved { - Ok(r) => Ok(record::after(r, &healed, &record::counts(&[body]))), - // 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), - } - }; - match crate::guard("offset faces", || run(b)) { - Ok(r) => Ok(r), - // A face that runs along a seam edge twice — a hole's wall as a STEP file writes it, one - // whole turn bounded by one wire. Healing it, or moving its neighbour, fails where the - // same body with that face in two halves works, so split it and ask again, which is what - // a boolean already does (`seam_faces`). - Err(e) => match crate::seam_faces::one_body(b, "offset faces off their seams", &run) { - Some(Ok(r)) => Ok(r), - _ => 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()); + #[cfg(not(feature = "smlib"))] + { + let _ = chosen; + Err(crate::smlib_sweeps::not_built()) } } diff --git a/crates/kernel/src/smlib_sweeps.rs b/crates/kernel/src/smlib_sweeps.rs index 93dd7ccb7a..4fa14e393a 100644 --- a/crates/kernel/src/smlib_sweeps.rs +++ b/crates/kernel/src/smlib_sweeps.rs @@ -71,14 +71,20 @@ pub(crate) struct Draft { } #[cfg(feature = "smlib")] -pub(crate) use imp::{extrude, loft, loft_rails, loft_to_point, revolve, screw, sweep, sweep_controlled}; +pub(crate) use imp::{extrude, loft, loft_rails, loft_to_point, revolve, screw, solid_from_record, sweep, sweep_controlled}; + +/// The error of an SMLib operation in a build without SMLib. +#[cfg(not(feature = "smlib"))] +pub(crate) fn not_built() -> KernelError { + not_supported("this build has no SMLib (feature `smlib`)") +} #[cfg(not(feature = "smlib"))] mod stubs { use super::*; - fn not_built() -> KernelError { - not_supported("this build has no SMLib (feature `smlib`)") + pub(crate) fn solid_from_record(_: &solidcraft_geom::brep::Brep, _: &str) -> Result { + Err(not_built()) } pub(crate) fn extrude(_: &Plane, _: &[Piece], _: Option<&Region2>, _: f64, _: f64, _: f64, _: Option) -> Result { Err(not_built()) @@ -106,7 +112,7 @@ mod stubs { } } #[cfg(not(feature = "smlib"))] -pub(crate) use stubs::{extrude, loft, loft_rails, loft_to_point, revolve, screw, sweep, sweep_controlled}; +pub(crate) use stubs::{extrude, loft, loft_rails, loft_to_point, revolve, screw, solid_from_record, sweep, sweep_controlled}; /// Refuses what no SMLib sweep does, before any work. pub(crate) fn refuse(what: &str) -> KernelError { @@ -183,6 +189,13 @@ mod imp { crate::native::body_from_smlib(brep) } + /// The solid a schema 4 record describes, taken by SMLib as a modeller's own record (no + /// healing) and checked there. + pub(crate) fn solid_from_record(record: &solidcraft_geom::brep::Brep, what: &str) -> Result { + let brep = Brep::from_record(record, true).map_err(|e| failed(what, e))?; + body_of(&brep, what) + } + /// A point a wall is known by: on its edge in the start plane, or inside it; and the piece /// (by index) that sweeps it. struct Seed {