Files
OneCAD/crates/kernel/src/surfaces.rs
T
Irakli KourtisandClaude Opus 5.5 e540832626 Surface Extrude: sheets from open curves and profile walls (#815)
surface.extrude sweeps open curve chains and profile boundaries along the sketch normal into a surface body (walls only, no caps). Saved under format 17.

Refs #757

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:54:38 -04:00

805 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Surface bodies (open shells) and the operations that turn them into solids: Patch (a face
//! filling a closed loop of edges — planar, or B-spline patches for a loop that is not — or a
//! planar sketch region), Stitch (surfaces sharing edges sewn
//! into one body, a solid when they close), Trim (a surface cut by a tool, one side kept),
//! Thicken (a surface offset along its normals into a solid), copying faces out of a body, and
//! sheets swept from sketch curves (surface extrude).
use std::collections::HashMap;
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface3D, SearchNearestParameter};
use solidcraft_geom::{Plane, Region2, Seg2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
use crate::history::{Change, EdgeOrigin, FaceOrigin};
use crate::record::{Rebuild, rebuilt_body};
use crate::{KernelError, Result, guard};
impl Body {
/// A surface body: some shell does not close.
pub fn is_surface(&self) -> bool {
!self.is_mesh() && self.solid.boundaries().iter().any(|s| s.shell_condition() != mt::ShellCondition::Closed)
}
}
/// A body of faces as they are (no orientation fix: a surface has no inside).
pub(crate) fn surface_body(faces: Vec<mt::Face>) -> Body {
surface_of(mt::Shell::from(faces))
}
/// A body of a shell as it is.
fn surface_of(shell: mt::Shell) -> Body {
Body {
solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])),
size: 0.0,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: None,
mesh: None,
color: None,
paint: None,
splits: None,
face_sources: None,
history: None,
regularization_protected: false,
}
}
/// A body from faces: a solid when they close, else a surface; with the history of the
/// operation that made the faces from `operands` as `rec` records.
fn body_of(faces: Vec<mt::Face>, operands: &[&mt::Solid], rec: &Rebuild) -> Result<Body> {
let shell = mt::Shell::from(faces);
if shell.shell_condition() == mt::ShellCondition::Closed {
crate::record::rebuilt(mt::Solid::new_unchecked(vec![shell]), operands, rec)
} else {
Ok(rebuilt_body(surface_of(shell), operands, rec))
}
}
/// A planar surface filling a sketch region. Its history has no operands: the face is new.
pub fn patch_region(plane: &Plane, region: &Region2) -> Result<Body> {
let f = crate::build::face(plane, region)?;
let mut rec = Rebuild::default();
rec.made(&f, Vec::new());
Ok(rebuilt_body(surface_body(vec![f]), &[], &rec))
}
/// Most curve chains one surface extrude takes.
const MAX_CHAINS: usize = 10_000;
/// A surface extruded from chains of sketch segments on `plane`: each chain (open, or closed
/// such as a profile's boundary) moved straight from `start` to `end` along the plane normal
/// (offsets in mm). The walls it sweeps form one surface body with no caps. Its history has no
/// operands: every face is new.
pub fn extrude_sheet(plane: &Plane, chains: &[Vec<Seg2>], start: f64, end: f64) -> Result<Body> {
if !crate::build::frame_ok(plane) {
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
}
if !(start.is_finite() && end.is_finite() && start.abs() < 1e7 && end.abs() < 1e7) {
return Err(KernelError::Invalid("extrude distance is not finite".into()));
}
let (lo, hi) = if end >= start { (start, end) } else { (end, start) };
if hi - lo < 1e-6 {
return Err(KernelError::Invalid("extrude distance is zero".into()));
}
if chains.is_empty() || chains.len() > MAX_CHAINS {
return Err(KernelError::Invalid("pick the curves or profiles to extrude".into()));
}
let base = plane.offset(lo);
let along = crate::body::v3(plane.normal() * (hi - lo));
let mut rec = Rebuild::default();
let mut faces = Vec::new();
for chain in chains {
let wire = crate::build::chain_wire(&base, chain)?;
let walls: mt::Shell = guard("surface extrude", || Ok(mt::builder::tsweep(&wire, along)))?;
for f in walls.face_iter() {
rec.made(f, Vec::new());
faces.push(f.clone());
}
}
Ok(rebuilt_body(surface_body(faces), &[], &rec))
}
/// The edge of a body nearest a point (by samples along each edge).
pub(crate) fn edge_near(body: &Body, p: Vec3) -> Option<mt::Edge> {
let mut best: Option<(f64, mt::Edge)> = None;
for e in body.solid.edge_iter() {
let c = e.curve();
let (t0, t1) = c.range_tuple();
for k in 0..=32 {
let d = from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 32.0)).dist(p);
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
best = Some((d, e.clone()));
}
}
}
best.filter(|(d, _)| *d <= body.size() * 0.05 + 1e-6).map(|(_, e)| e)
}
/// A surface filling a closed loop of a body's edges (each picked by a point on it): one face on
/// the body's own edges, planar when the loop is certainly planar ([`crate::plane_fit`]), else a
/// B-spline height field over the loop's plane ([`crate::fill`]; loops that do not bound one are
/// refused). Its history is over `[body]`: the face is new, its edges the body's own.
pub fn patch_edges(body: &Body, at: &[Vec3]) -> Result<Body> {
body.require_brep("patch")?;
if at.is_empty() || at.len() > 10_000 {
return Err(KernelError::Invalid("pick the edges of a closed loop".into()));
}
guard("patch", || {
let chain = picked_loop(body, at)?;
let Some(face) = crate::plane_fit::planar_face(&chain, crate::fill::planarity_tolerance(body))? else {
// Not certainly planar: a fitted surface.
return crate::fill::fill_loop(body, &chain);
};
// A new face on the body's own edges.
let mut rec = Rebuild::default();
rec.made(&face, Vec::new());
Ok(rebuilt_body(surface_body(vec![face]), &[&body.solid], &rec))
})
}
/// The edges of `body` picked by points on them, chained into one closed loop (each turned to
/// run along it). Call it under [`guard`]: sampling an intersection curve can fail in truck.
pub(crate) fn picked_loop(body: &Body, at: &[Vec3]) -> Result<Vec<mt::Edge>> {
let mut edges: Vec<mt::Edge> = Vec::new();
for p in at {
let e = edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?;
if !edges.iter().any(|x| x.id() == e.id()) {
edges.push(e);
}
}
let mut pool = edges;
let first = pool.pop().ok_or_else(|| KernelError::Invalid("no edges".into()))?;
let start = first.front().id();
let mut at_v = first.back().clone();
let mut chain = vec![first];
while at_v.id() != start {
let k = pool
.iter()
.position(|e| e.front().id() == at_v.id() || e.back().id() == at_v.id())
.ok_or_else(|| KernelError::Invalid("the edges do not form a closed loop".into()))?;
let e = pool.remove(k);
let e = if e.front().id() == at_v.id() { e } else { e.inverse() };
at_v = e.back().clone();
chain.push(e);
}
if !pool.is_empty() {
return Err(KernelError::Invalid("the edges form more than one loop".into()));
}
Ok(chain)
}
/// Faces of a body copied out as a surface body (faces picked by points on them). Its history is
/// over `[body]`: the faces and their edges are the body's.
pub fn copy_faces(body: &Body, at: &[Vec3]) -> Result<Body> {
body.require_brep("copy faces")?;
let faces: Vec<&mt::Face> = body.solid.face_iter().collect();
let mut chosen = Vec::new();
for p in at {
// The face whose mesh passes nearest the point.
let i = nearest_face(body, *p)?;
if !chosen.contains(&i) {
chosen.push(i);
}
}
let copied = surface_body(chosen.into_iter().filter_map(|i| faces.get(i).map(|f| (*f).clone())).collect());
Ok(rebuilt_body(copied, &[&body.solid], &Rebuild::default()))
}
/// Index of the face nearest a point (over its mesh triangles).
fn nearest_face(body: &Body, p: Vec3) -> Result<usize> {
let m = body.tessellate(body.size() * 2e-3)?;
let mut best: Option<(f64, u32)> = None;
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let Some([a, b, c]) = m.tri(t) else { continue };
let d = ((a + b + c) / 3.0).dist(p).min(a.dist(p)).min(b.dist(p)).min(c.dist(p));
if best.is_none_or(|(x, _)| d < x) {
best = Some((d, *f));
}
}
best.map(|(_, f)| f as usize).ok_or_else(|| KernelError::Invalid("no face there".into()))
}
/// Sew surface bodies along edges that coincide (within `tol`): one body, a solid when the
/// faces close up. Faces are turned so that neighbours agree. The result's history is over
/// `bodies`: each face the one it was sewn from, each edge the edges sewn into it.
pub fn stitch(bodies: &[&Body], tol: f64) -> Result<Body> {
if bodies.is_empty() {
return Err(KernelError::Invalid("nothing to stitch".into()));
}
for b in bodies {
b.require_brep("stitch")?;
}
let size = bodies.iter().map(|b| b.size()).fold(0.0, f64::max);
let tol = if tol.is_finite() && tol > 0.0 { tol } else { size * 1e-6 + 1e-6 };
guard("stitch", || {
let faces: Vec<mt::Face> = bodies.iter().flat_map(|b| b.solid.face_iter().cloned().collect::<Vec<_>>()).collect();
// Shared vertices by position, shared edges by their ends and middle.
let mut verts: Vec<mt::Vertex> = Vec::new();
let mut vmap: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
let mut canon = |v: &mt::Vertex, verts: &mut Vec<mt::Vertex>| -> mt::Vertex {
if let Some(c) = vmap.get(&v.id()) {
return c.clone();
}
let p = v.point();
let c = match verts.iter().find(|u| u.point().distance(p) <= tol) {
Some(u) => u.clone(),
None => {
verts.push(v.clone());
v.clone()
}
};
vmap.insert(v.id(), c.clone());
c
};
let mut edges: Vec<(mt::Edge, mt::Point3)> = Vec::new();
let mut out = Vec::with_capacity(faces.len());
let mut rec = Rebuild::default();
for f in &faces {
let mut wires = Vec::new();
for w in f.absolute_boundaries() {
let mut nw = Vec::new();
for e in w.edge_iter() {
let abs = if e.orientation() { e.clone() } else { e.inverse() };
let (a, b) = (canon(abs.front(), &mut verts), canon(abs.back(), &mut verts));
let c = abs.curve();
let (t0, t1) = c.range_tuple();
let mid = c.subs(0.5 * (t0 + t1));
let found = edges.iter().find_map(|(x, m)| {
if m.distance(mid) > tol * 10.0 {
return None;
}
if x.front().id() == a.id() && x.back().id() == b.id() {
Some(x.clone())
} else if x.front().id() == b.id() && x.back().id() == a.id() {
Some(x.inverse())
} else {
None
}
});
let ne = match found {
Some(x) => x,
None => {
let x = mt::Edge::new_unchecked(&a, &b, c);
edges.push((x.clone(), mid));
x
}
};
rec.piece(&ne, &abs);
nw.push(if e.orientation() { ne } else { ne.inverse() });
}
wires.push(mt::Wire::from(nw));
}
let mut nf = mt::Face::new_unchecked(wires, f.surface());
if !f.orientation() {
nf.invert();
}
rec.whole(&nf, f);
out.push(nf);
}
// Neighbours run their shared edges opposite ways.
let mut uses: HashMap<mt::EdgeID, Vec<(usize, bool)>> = HashMap::new();
for (i, f) in out.iter().enumerate() {
for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) {
uses.entry(e.id()).or_default().push((i, e.orientation()));
}
}
let mut flip: Vec<Option<bool>> = vec![None; out.len()];
for s in 0..out.len() {
if flip[s].is_some() {
continue;
}
flip[s] = Some(false);
let mut stack = vec![s];
while let Some(i) = stack.pop() {
let fl = flip[i].unwrap_or(false);
let Some(f) = out.get(i) else { continue };
for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) {
let d = e.orientation() != fl;
for &(j, dj) in uses.get(&e.id()).into_iter().flatten() {
if j != i && flip[j].is_none() {
flip[j] = Some(dj == d);
stack.push(j);
}
}
}
}
}
let out: Vec<mt::Face> = out.into_iter().zip(flip).map(|(f, fl)| if fl == Some(true) { f.inverse() } else { f }).collect();
let operands: Vec<&mt::Solid> = bodies.iter().map(|b| &*b.solid).collect();
body_of(out, &operands, &rec)
})
}
/// How a face moves along its normal: a plane slides, a circular cylinder changes radius.
#[derive(Clone, Copy)]
enum Offset {
Plane { n: Vec3 },
Cylinder { o: Vec3, axis: Vec3, r: f64, out: f64 },
}
impl Offset {
fn of(f: &mt::Face) -> Option<Offset> {
match crate::splitface::analytic_field(&f.oriented_surface())? {
crate::splitface::Analytic::Plane { n } => Some(Offset::Plane { n }),
crate::splitface::Analytic::Cylinder { o, axis, r, out } => Some(Offset::Cylinder { o, axis, r, out }),
}
}
fn map(&self, p: Vec3, d: f64) -> Vec3 {
match *self {
Offset::Plane { n } => p + n * d,
// Scaled about the axis (control points of a NURBS circle stay exact).
Offset::Cylinder { o, axis, r, out } => {
let v = p - o;
let radial = v - axis * v.dot(axis);
p + radial * (out * d / r)
}
}
}
}
fn map_pt(off: &Offset, p: mt::Point3, d: f64) -> mt::Point3 {
p3(off.map(from_p3(p), d))
}
/// A curve moved by an offset (control points mapped; exact for lines, and for circles square
/// to a cylinder's axis or in a plane).
fn map_curve(off: &Offset, c: &mt::Curve, d: f64) -> Result<mt::Curve> {
Ok(match c {
mt::Curve::Line(l) => mt::Curve::Line(mt::Line(map_pt(off, l.0, d), map_pt(off, l.1, d))),
mt::Curve::BSplineCurve(b) => {
let pts: Vec<mt::Point3> = b.control_points().iter().map(|p| map_pt(off, *p, d)).collect();
mt::Curve::BSplineCurve(mt::BSplineCurve::new(b.knot_vec().clone(), pts))
}
mt::Curve::NurbsCurve(n) => {
let nb = n.non_rationalized();
let pts: Vec<mt::Vector4> = nb
.control_points()
.iter()
.map(|h| {
let w = h.w;
let q = map_pt(off, mt::Point3::new(h.x / w, h.y / w, h.z / w), d);
mt::Vector4::new(q.x * w, q.y * w, q.z * w, w)
})
.collect();
mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::new(nb.knot_vec().clone(), pts)))
}
other => {
// Anything else: a cubic through moved samples.
let (t0, t1) = other.range_tuple();
let pts: Vec<Vec3> = (0..=32).map(|k| off.map(from_p3(other.subs(t0 + (t1 - t0) * k as f64 / 32.0)), d)).collect();
mt::Curve::BSplineCurve(crate::build::interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("offset edge".into()))?)
}
})
}
/// A surface moved by an offset (control points mapped; exact for planes and NURBS cylinders).
fn map_surface(off: &Offset, s: &mt::Surface, d: f64) -> Result<mt::Surface> {
Ok(match s {
mt::Surface::Plane(pl) => {
let (o, a, b) = (pl.origin(), pl.u_axis(), pl.v_axis());
let o2 = map_pt(off, o, d);
mt::Surface::Plane(mt::Plane::new(o2, o2 + a, o2 + b))
}
mt::Surface::BSplineSurface(b) => {
let rows: Vec<Vec<mt::Point3>> = b.control_points().iter().map(|r| r.iter().map(|p| map_pt(off, *p, d)).collect()).collect();
mt::Surface::BSplineSurface(mt::BSplineSurface::new((b.uknot_vec().clone(), b.vknot_vec().clone()), rows))
}
mt::Surface::NurbsSurface(n) => {
let nb = n.non_rationalized();
let rows: Vec<Vec<mt::Vector4>> = nb
.control_points()
.iter()
.map(|r| {
r.iter()
.map(|h| {
let w = h.w;
let q = map_pt(off, mt::Point3::new(h.x / w, h.y / w, h.z / w), d);
mt::Vector4::new(q.x * w, q.y * w, q.z * w, w)
})
.collect()
})
.collect();
mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new((nb.uknot_vec().clone(), nb.vknot_vec().clone()), rows)))
}
_ => return Err(KernelError::Invalid("not supported yet: thickening this kind of surface".into())),
})
}
/// A surface body thickened into a solid: offset along the faces' normals by `thickness`
/// (negative: against them), or by half each way when `symmetric`. Planar faces and faces on
/// circular cylinders; neighbouring faces must meet smoothly (their offsets agree).
pub fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result<Body> {
body.require_brep("thicken")?;
if !(thickness.is_finite() && thickness.abs() > 1e-9) {
return Err(KernelError::Invalid("thickness must be non-zero".into()));
}
let (d0, d1) = if symmetric { (-thickness / 2.0, thickness / 2.0) } else { (0.0, thickness) };
let (lo, hi) = (d0.min(d1), d0.max(d1));
let size = body.size();
let tol = size * 1e-7 + 1e-9;
guard("thicken", || {
let faces: Vec<mt::Face> = body.solid.face_iter().cloned().collect();
if faces.is_empty() {
return Err(KernelError::Invalid("nothing to thicken".into()));
}
let offs: Vec<Offset> = faces
.iter()
.map(|f| {
Offset::of(f).ok_or_else(|| KernelError::Invalid("not supported yet: thickening faces that are not planar or cylindrical".into()))
})
.collect::<Result<_>>()?;
// Each vertex and edge moved to both sides (once; every face using it must agree).
let mut vmoved: HashMap<mt::VertexID, (mt::Vertex, mt::Vertex)> = HashMap::new();
let mut emoved: HashMap<mt::EdgeID, (mt::Edge, mt::Edge)> = HashMap::new();
let mut uses: HashMap<mt::EdgeID, usize> = HashMap::new();
for (f, off) in faces.iter().zip(&offs) {
for e in f.absolute_boundaries().iter().flat_map(|w| w.edge_iter()) {
let abs = if e.orientation() { e.clone() } else { e.inverse() };
*uses.entry(abs.id()).or_default() += 1;
for v in [abs.front(), abs.back()] {
let (a, b) = (map_pt(off, v.point(), lo), map_pt(off, v.point(), hi));
match vmoved.get(&v.id()) {
Some((va, vb)) => {
if va.point().distance(a) > tol * 1e3 || vb.point().distance(b) > tol * 1e3 {
return Err(KernelError::Invalid("not supported yet: thickening across a sharp edge between faces".into()));
}
}
None => {
vmoved.insert(v.id(), (mt::Vertex::new(a), mt::Vertex::new(b)));
}
}
}
if let std::collections::hash_map::Entry::Vacant(slot) = emoved.entry(abs.id()) {
let (Some((a0, a1)), Some((b0, b1))) = (vmoved.get(&abs.front().id()).cloned(), vmoved.get(&abs.back().id()).cloned()) else {
continue;
};
let c = abs.curve();
let lo_e = mt::Edge::new_unchecked(&a0, &b0, map_curve(off, &c, lo)?);
let hi_e = mt::Edge::new_unchecked(&a1, &b1, map_curve(off, &c, hi)?);
slot.insert((lo_e, hi_e));
}
}
}
// Lines joining each vertex's two copies.
let mut sides: HashMap<mt::VertexID, mt::Edge> = HashMap::new();
for (id, (a, b)) in &vmoved {
sides.insert(*id, mt::Edge::new_unchecked(a, b, mt::Curve::Line(mt::Line(a.point(), b.point()))));
}
let moved_face = |f: &mt::Face, off: &Offset, which: usize, d: f64| -> Result<mt::Face> {
let wires: Vec<mt::Wire> = f
.absolute_boundaries()
.iter()
.map(|w| {
w.edge_iter()
.filter_map(|e| {
let abs_id = if e.orientation() { e.id() } else { e.inverse().id() };
let (a, b) = emoved.get(&abs_id)?;
let m = if which == 0 { a.clone() } else { b.clone() };
Some(if e.orientation() { m } else { m.inverse() })
})
.collect::<Vec<_>>()
.into()
})
.collect();
let mut nf = mt::Face::new_unchecked(wires, map_surface(off, &f.surface(), d)?);
if !f.orientation() {
nf.invert();
}
Ok(nf)
};
let mut out = Vec::new();
// History (one operand, the surface): a copy at no distance is the face itself, a copy
// further out its offset, a wall is made along its free edge.
let operand = crate::record::Operand::new(body);
let edge_index = &operand.edges;
let mut history = Vec::new();
let copy = |i: usize, d: f64| {
if d == 0.0 {
crate::record::from_face(0, i, Change::Kept)
} else {
crate::record::generated(vec![FaceOrigin::Offset { operand: 0, face: i }])
}
};
for (i, (f, off)) in faces.iter().zip(&offs).enumerate() {
// The lower copy faces back, the upper one forward.
out.push(moved_face(f, off, 0, lo)?.inverse());
out.push(moved_face(f, off, 1, hi)?);
history.extend([copy(i, lo), copy(i, hi)]);
// Walls along free edges, outward across the edge.
for w in f.boundaries() {
for e in w.edge_iter() {
let abs_id = if e.orientation() { e.id() } else { e.inverse().id() };
if uses.get(&abs_id).copied().unwrap_or(0) != 1 {
continue;
}
let Some((le, he)) = emoved.get(&abs_id) else { continue };
let (le, he) = if e.orientation() { (le.clone(), he.clone()) } else { (le.inverse(), he.inverse()) };
let (Some(sa), Some(sb)) = (sides.get(&e.front().id()), sides.get(&e.back().id())) else { continue };
// A → B low, B → B' up, B' → A' high back, A' → A down.
let wire: mt::Wire = vec![le.clone(), sb.clone(), he.inverse(), sa.inverse()].into();
let ruled = mt::builder::homotopy(&le, &he);
let surf = ruled.surface();
// The wall's outward normal: across the edge, away from the face.
let c = le.oriented_curve();
let (t0, t1) = c.range_tuple();
let tm = 0.5 * (t0 + t1);
let p = c.subs(tm);
let tangent = c.der(tm);
let n =
f.oriented_surface().search_nearest_parameter(p, None, 50).map(|(u, v)| f.oriented_surface().normal(u, v)).unwrap_or(tangent);
let outward = tangent.cross(n);
let sn = surf.search_nearest_parameter(p, None, 50).map(|(u, v)| surf.normal(u, v)).unwrap_or(outward);
let wall = if sn.dot(outward) >= 0.0 {
mt::Face::new_unchecked(vec![wire], surf)
} else {
let mut x = mt::Face::new_unchecked(vec![wire.inverse()], surf);
x.invert();
x
};
out.push(wall);
let edge = edge_index.get(&abs_id).copied().ok_or_else(|| KernelError::Failed("thicken: an edge outside the surface".into()))?;
history.push(crate::record::generated(vec![FaceOrigin::Edge { operand: 0, edge }]));
}
}
}
let shell = mt::Shell::from(out);
if shell.shell_condition() != mt::ShellCondition::Closed {
return Err(KernelError::Failed("the thickened surface does not close".into()));
}
// The edges copied at no distance are the surface's own edges, rebuilt.
let mut copies = HashMap::new();
for (id, (low, high)) in &emoved {
for (copy, d) in [(low, lo), (high, hi)] {
if let Some(&edge) = edge_index.get(id)
&& d == 0.0
{
copies.insert(copy.id(), EdgeOrigin::Edge { operand: 0, edge });
}
}
}
crate::record::attach(Body::new(mt::Solid::new_unchecked(vec![shell]))?, &[operand], Some(history), copies)
})
}
/// A surface body trimmed by a tool: its faces are cut where the tool meets them and the
/// pieces on the side of `keep` are kept. The result's history is over `[body]`.
pub fn trim(body: &Body, tool: &crate::SplitTool, keep: Vec3) -> Result<Body> {
body.require_brep("trim")?;
let total = body.solid.face_iter().count();
let all: Vec<usize> = (0..total).collect();
let (split, _) = crate::splitface::split_any(body, &all, tool)?;
let side = crate::splitface::side_of_point(tool, keep)?;
guard("trim", || {
let mut kept = Vec::new();
for f in split.solid.face_iter() {
let x = crate::splitface::face_side(f, tool, split.size())?;
if (x >= 0.0) == (side >= 0.0) {
kept.push(f.clone());
}
}
if kept.is_empty() {
return Err(KernelError::Invalid("nothing is left on that side of the tool".into()));
}
// The kept pieces are the split's own; its history carries them back to `body`.
let trimmed = rebuilt_body(surface_body(kept), &[&split.solid], &Rebuild::default());
Ok(crate::record::after(trimmed, &split, &crate::record::counts(&[body])))
})
}
/// A planar surface extended: the chosen straight edges (picked by points on them) move
/// outward in the face's plane by `distance`, their neighbours lengthened to meet them. The
/// result's history is over `[body]`: the face and each edge the one it was moved from.
pub fn extend(body: &Body, at: &[Vec3], distance: f64) -> Result<Body> {
body.require_brep("extend")?;
if !(distance.is_finite() && distance > 0.0) {
return Err(KernelError::Invalid("the extension distance must be positive".into()));
}
let faces: Vec<mt::Face> = body.solid.face_iter().cloned().collect();
let [face] = faces.as_slice() else { return Err(KernelError::Invalid("not supported yet: extending a surface of more than one face".into())) };
let Some(crate::splitface::Analytic::Plane { n }) = crate::splitface::analytic_field(&face.oriented_surface()) else {
return Err(KernelError::Invalid("not supported yet: extending a surface that is not planar".into()));
};
let chosen: Vec<mt::EdgeID> = at
.iter()
.map(|p| edge_near(body, *p).map(|e| e.id()).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}"))))
.collect::<Result<_>>()?;
guard("extend", || {
let bounds = face.boundaries();
let [outer] = bounds.as_slice() else { return Err(KernelError::Invalid("not supported yet: extending a surface with holes".into())) };
let edges: Vec<mt::Edge> = outer.edge_iter().cloned().collect();
let m = edges.len();
// Each edge as a line (point, direction) moved outward when chosen.
let mut lines = Vec::with_capacity(m);
for e in &edges {
let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point()));
if !matches!(e.curve(), mt::Curve::Line(_)) {
return Err(KernelError::Invalid("not supported yet: extending a surface with curved edges".into()));
}
let d = (b - a).normalized().ok_or_else(|| KernelError::Invalid("a zero-length edge".into()))?;
// Outward: right of the edge seen along the normal (the face is on its left).
let out = d.cross(n);
let shift = if chosen.contains(&e.id()) || chosen.contains(&e.inverse().id()) { distance } else { 0.0 };
lines.push((a + out * shift, d));
}
// Corners: where consecutive lines meet.
let meet = |(p, d): (Vec3, Vec3), (q, e): (Vec3, Vec3)| -> Option<Vec3> {
let w = q - p;
let c = d.cross(e);
let cc = c.dot(c);
if cc < 1e-24 {
return None;
}
Some(p + d * (w.cross(e).dot(c) / cc))
};
let mut corners = Vec::with_capacity(m);
for i in 0..m {
let (Some(prev), Some(cur)) = (lines.get((i + m - 1) % m), lines.get(i)) else { continue };
let p = meet(*prev, *cur).unwrap_or(cur.0);
corners.push(mt::Vertex::new(p3(p)));
}
let mut rec = Rebuild::default();
let wire: mt::Wire = (0..m)
.filter_map(|i| {
let (a, b, old) = (corners.get(i)?, corners.get((i + 1) % m)?, edges.get(i)?);
let e = mt::Edge::new_unchecked(a, b, mt::Curve::Line(mt::Line(a.point(), b.point())));
rec.piece(&e, old);
Some(e)
})
.collect::<Vec<_>>()
.into();
let mut f = mt::builder::try_attach_plane(&[wire]).map_err(|e| KernelError::Failed(format!("extend: {e}")))?;
if f.oriented_surface().normal(0.0, 0.0).dot(crate::body::v3(n)) < 0.0 {
f = f.inverse();
}
rec.face(&f, face);
Ok(rebuilt_body(surface_body(vec![f]), &[&body.solid], &rec))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{box_solid, cylinder, measure};
use solidcraft_geom::{Loop2, Vec2};
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-9)
}
/// Walls swept from an open line, an open arc and a closed square: areas are length × height,
/// no caps, and stitching two patches onto the square's walls closes the box.
#[test]
fn extrude_sheet_of_open_and_closed_chains() {
let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }];
let s = extrude_sheet(&Plane::XY, std::slice::from_ref(&line), 0.0, 5.0).unwrap();
assert!(s.is_surface() && s.face_count() == 1);
assert!(rel(measure(&s).unwrap().area, 50.0) < 1e-9);
// A quarter arc of radius 4, swept from -1 to 2: area r θ h.
let arc = vec![Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: 4.0, start: 0.0, sweep: std::f64::consts::FRAC_PI_2 }];
let a = measure(&extrude_sheet(&Plane::XY, &[arc], 2.0, -1.0).unwrap()).unwrap().area;
let want = 4.0 * std::f64::consts::FRAC_PI_2 * 3.0;
assert!(rel(a, want) < 1e-3, "{a} vs {want}"); // (measured on meshes)
// A line then a line back up: one chain, two faces.
let bent = vec![line[0], Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 6.0) }];
assert_eq!(extrude_sheet(&Plane::XY, &[bent], 0.0, 1.0).unwrap().face_count(), 2);
// A closed square: four walls, open at both ends; with a patch on each end, a solid box.
let sq = Region2 {
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]),
holes: vec![],
};
let walls = extrude_sheet(&Plane::XY, std::slice::from_ref(&sq.outer.segs), 0.0, 4.0).unwrap();
assert!(walls.is_surface() && walls.face_count() == 4);
assert!(rel(measure(&walls).unwrap().area, 160.0) < 1e-9);
let (bottom, top) = (patch_region(&Plane::XY, &sq).unwrap(), patch_region(&Plane::XY.offset(4.0), &sq).unwrap());
let closed = stitch(&[&walls, &bottom, &top], 1e-6).unwrap();
assert!(!closed.is_surface());
assert!(rel(measure(&closed).unwrap().volume, 400.0) < 1e-9);
}
#[test]
fn extrude_sheet_refuses_bad_input() {
let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }];
assert!(extrude_sheet(&Plane::XY, std::slice::from_ref(&line), 0.0, 0.0).is_err());
assert!(extrude_sheet(&Plane::XY, std::slice::from_ref(&line), 0.0, f64::NAN).is_err());
assert!(extrude_sheet(&Plane::XY, &[], 0.0, 1.0).is_err());
assert!(extrude_sheet(&Plane::XY, &[vec![]], 0.0, 1.0).is_err());
let gap = vec![line[0], Seg2::Line { a: Vec2::new(11.0, 0.0), b: Vec2::new(12.0, 0.0) }];
assert!(extrude_sheet(&Plane::XY, &[gap], 0.0, 1.0).is_err());
let nan = vec![Seg2::Line { a: Vec2::new(0.0, f64::NAN), b: Vec2::new(1.0, 0.0) }];
assert!(extrude_sheet(&Plane::XY, &[nan], 0.0, 1.0).is_err());
}
#[test]
fn patch_and_thicken_a_square() {
let r = Region2 {
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]),
holes: vec![],
};
let s = patch_region(&Plane::XY, &r).unwrap();
assert!(s.is_surface());
let t = thicken(&s, 2.0, false).unwrap();
assert!(!t.is_surface());
assert!(rel(measure(&t).unwrap().volume, 200.0) < 1e-9);
let t = thicken(&s, 2.0, true).unwrap();
let m = measure(&t).unwrap();
assert!(rel(m.volume, 200.0) < 1e-9 && (m.bbox.min.z + 1.0).abs() < 1e-9, "{:?}", m.bbox);
}
#[test]
fn thicken_a_cylinder_side_and_a_box_top() {
let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
// One half of the side, offset outward by 1: half of a ring.
let half = copy_faces(&c, &[Vec3::new(7.0, 7.0, 10.0)]).unwrap();
let v = measure(&thicken(&half, 1.0, false).unwrap()).unwrap().volume;
let want = std::f64::consts::PI * (121.0 - 100.0) / 2.0 * 20.0;
assert!(rel(v, want) < 3e-4, "{v} vs {want}"); // (measured on meshes)
// Both halves (meeting smoothly): the whole ring, inward.
let d = 10.0 / 2f64.sqrt();
let side = copy_faces(&c, &[Vec3::new(d, d, 10.0), Vec3::new(-d, -d, 10.0)]).unwrap();
assert_eq!(side.face_count(), 2);
let v = measure(&thicken(&side, -1.0, false).unwrap()).unwrap().volume;
let want = std::f64::consts::PI * (100.0 - 81.0) * 20.0;
assert!(rel(v, want) < 3e-4, "{v} vs {want}"); // (measured on meshes)
// Faces meeting at a sharp edge are refused.
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let two = copy_faces(&b, &[Vec3::new(5.0, 5.0, 10.0), Vec3::new(5.0, 0.0, 5.0)]).unwrap();
assert!(thicken(&two, 1.0, false).is_err());
}
#[test]
fn stitch_six_faces_into_a_box_and_patch_a_hole() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let pts = [
Vec3::new(5.0, 10.0, 0.0),
Vec3::new(5.0, 10.0, 30.0),
Vec3::new(0.0, 10.0, 15.0),
Vec3::new(10.0, 10.0, 15.0),
Vec3::new(5.0, 0.0, 15.0),
Vec3::new(5.0, 20.0, 15.0),
];
let parts: Vec<Body> = pts.iter().map(|p| copy_faces(&b, &[*p]).unwrap()).collect();
let refs: Vec<&Body> = parts.iter().collect();
let s = stitch(&refs, 0.0).unwrap();
assert!(!s.is_surface());
assert!(rel(measure(&s).unwrap().volume, 6000.0) < 1e-9);
// Five faces leave an open box; patching the top's edges closes it again.
let open = stitch(&[refs[0], refs[2], refs[3], refs[4], refs[5]], 0.0).unwrap();
assert!(open.is_surface());
let lid =
patch_edges(&open, &[Vec3::new(5.0, 0.0, 30.0), Vec3::new(10.0, 10.0, 30.0), Vec3::new(5.0, 20.0, 30.0), Vec3::new(0.0, 10.0, 30.0)])
.unwrap();
let closed = stitch(&[&open, &lid], 0.0).unwrap();
assert!(!closed.is_surface());
assert!(rel(measure(&closed).unwrap().volume, 6000.0) < 1e-9);
}
#[test]
fn extend_a_planar_surface() {
let r = Region2 {
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]),
holes: vec![],
};
let s = patch_region(&Plane::XY, &r).unwrap();
let e = extend(&s, &[Vec3::new(10.0, 5.0, 0.0)], 5.0).unwrap();
assert!(rel(measure(&e).unwrap().area, 150.0) < 1e-9);
let e = extend(&s, &[Vec3::new(10.0, 5.0, 0.0), Vec3::new(5.0, 10.0, 0.0)], 5.0).unwrap();
assert!(rel(measure(&e).unwrap().area, 225.0) < 1e-9);
assert!(extend(&s, &[Vec3::new(10.0, 5.0, 0.0)], -1.0).is_err());
}
#[test]
fn trim_keeps_one_side() {
let r = Region2 {
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]),
holes: vec![],
};
let s = patch_region(&Plane::XY, &r).unwrap();
let pl = Plane::new(Vec3::new(3.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
let t = trim(&s, &crate::SplitTool::Plane(pl), Vec3::new(1.0, 5.0, 0.0)).unwrap();
assert!(rel(measure(&t).unwrap().area, 30.0) < 1e-9);
let t = trim(&s, &crate::SplitTool::Plane(pl), Vec3::new(8.0, 5.0, 0.0)).unwrap();
assert!(rel(measure(&t).unwrap().area, 70.0) < 1e-9);
}
}