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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-22 00:36:18 -04:00
parent 2b77f09f86
commit a5634078ef
2 changed files with 150 additions and 808 deletions
+133 -804
View File
@@ -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<Vec3>, Vec<(usize, Vec<usize>)
/// The convex polyhedron bounded by the half-spaces.
pub fn convex_polyhedron(hs: &[HalfSpace]) -> Result<Body> {
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<usize>)> {
let (verts, faces_idx) = combinatorics(hs)?;
let on: Vec<usize> = faces_idx.iter().map(|(hi, _)| *hi).collect();
guard("polyhedron", || {
let tv: Vec<mt::Vertex> = verts.iter().map(|v| builder::vertex(p3(*v))).collect();
let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new();
let mut faces: Vec<mt::Face> = Vec::new();
for (_, f) in &faces_idx {
let n = f.len();
let mut wire: Vec<mt::Edge> = 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<usize>)]) -> Result<rec::Brep> {
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<EdgeUseRef>> = 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<Vec<(HalfSpace, Vec3, Vec<usize>)>> {
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<usize>)> = 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<Vec3> = 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<usize, u32> = HashMap::new();
let mut vertices: Vec<rec::Vertex> = 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<u32> {
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<usize>]) -> HashMap<(usize, usize), Vec<usize>> {
let plane_of: HashMap<usize, usize> = groups.iter().enumerate().flat_map(|(k, g)| g.iter().map(move |f| (*f, k))).collect();
let mut out: HashMap<(usize, usize), Vec<usize>> = 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<usize>], b: &Body) -> Option<History> {
let faces: Vec<FaceProv> = on.iter().map(|k| groups.get(*k).map(|under| on_plane(under))).collect::<Option<_>>()?;
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<usize>]) -> Option<History> {
let faces = on
.iter()
.map(|k| groups.get(*k).map(|under| record::generated(under.iter().map(|&face| FaceOrigin::Offset { operand: 0, face }).collect())))
.collect::<Option<_>>()?;
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<Body> {
#[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<Body> {
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<HalfSpace> = 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<Vec<usize>> = 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<History>) -> 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<History> {
let h = offset.history()?;
let faces: Vec<FaceProv> = 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::<Option<_>>()?;
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<Body> {
/// 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<Body> {
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<Vec<usize>> = 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<Body> {
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<usize> = 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<Body> {
let planes = planar_convex(b)?;
let hs: Vec<HalfSpace> = planes.iter().map(|(h, ..)| *h).collect();
let groups: Vec<Vec<usize>> = 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<FaceOrigin> {
edges.get(&(f.min(g), f.max(g))).into_iter().flatten().map(|&edge| FaceOrigin::Edge { operand: 0, edge }).collect()
};
let inner: Vec<HalfSpace> = 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<usize>> = 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<mt::Face> = Vec::new();
// What each face is made from (in `out`'s order).
let mut made: Vec<FaceProv> = Vec::new();
// Planar faces.
for (hi, lp) in &faces {
let n = lp.len();
let mut w: Vec<mt::Edge> = 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<mt::Edge> =
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<usize> = 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<mt::Edge> = (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<FaceOrigin> =
(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<Body> {
// 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<usize> = 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<Option<Body>> {
use crate::offset::Surf;
if b.solid.boundaries().len() != 1 || b.face_count() > 256 {
return Ok(None);
}
let faces: Vec<mt::Face> = b.solid.face_iter().cloned().collect();
let tol = (b.size() * 1e-7).max(1e-9) * 100.0;
let surfs: Option<Vec<Surf>> = 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<mt::Face> = 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<f64> {
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<Body> {
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<usize> = 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<Option<Body>> {
let size = healed.size();
let tol = size * 1e-6 + 1e-9;
let faces: Vec<mt::Face> = 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<Body> = 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<Body> {
let closed = offset_planar(b, |_, _| -thickness)?;
let faces: Vec<mt::Face> = 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<Body> {
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<Vec<usize>> {
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<Body> {
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<usize> = 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<Vec<usize>> {
_ => 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<Body> {
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<Body> {
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<Body> {
let faces: Vec<mt::Face> = 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())
}
}
+17 -4
View File
@@ -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<Body> {
Err(not_built())
}
pub(crate) fn extrude(_: &Plane, _: &[Piece], _: Option<&Region2>, _: f64, _: f64, _: f64, _: Option<Draft>) -> Result<Body> {
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<Body> {
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 {