kernel: extrude, revolve, sweep, loft and coil through SMLib behind SOLIDCRAFT_KERNEL

With SOLIDCRAFT_KERNEL=smlib, extrude (and extrude_whole_sides, so boxes and cylinders too),
revolve and revolve_whole, extrude_tapered(_between), sweep and sweep_with, loft,
loft_with_rails, loft_regions and sweep_helix (coils, and the threads' grooves) build their
bodies with the fork's sweeps (smlib_sweeps.rs): the profile's segments go to SMLib as curves
(lines, arcs, Béziers and conics as NURBS), the result comes back through its schema 4 record,
and one SMLib's own check refuses is an error. What SMLib has no sweep for answers "not
supported yet", never truck's sweep: twists, end scales, guide rails and orientation
directions; paths with corners and paths of points; lofts with holes or to a point; draft
extrusions of profiles with holes or with a straight foot.

History is in the builders' vocabulary, found from the result's geometry as provenance.rs does
for operations without exact history: a wall comes from the segments its start edge runs along
(and, for extrusions and revolutions, whose middles it holds half-way), the face in the start
plane where the profile bounds it is the start cap, the one at the end the end cap. Straight
extrusions also carry their faces' construction sources (ExtrudeCap, ExtrudeSide), which the
box and cylinder certificates need. The loft's section matching is truck's own, now
loft_numbered_loops (split out of loft_numbered_wires, unchanged), so both kernels pair the
same vertices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
jyviko
2026-10-21 08:36:53 -04:00
parent 62b5add282
commit fe7b37720b
7 changed files with 710 additions and 14 deletions
+102 -8
View File
@@ -156,7 +156,7 @@ fn attach_profile(wires: &[mt::Wire]) -> Result<mt::Face> {
guard("face", || builder::try_attach_plane(wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))
}
fn region_ok(r: &Region2) -> Result<()> {
pub(crate) fn region_ok(r: &Region2) -> Result<()> {
if r.area().abs() < 1e-9 {
return Err(KernelError::Invalid("profile has no area".into()));
}
@@ -215,6 +215,17 @@ fn extrude_with(plane: &Plane, regions: &[Region2], start: f64, end: f64, whole_
if regions.is_empty() {
return Err(KernelError::Invalid("no profile selected".into()));
}
if crate::backend()? == crate::Backend::Smlib {
return regions
.iter()
.map(|r| {
region_ok(r)?;
let pieces = oriented(r)?;
let numbered = if whole_sides { pieces.map_loops(|l| l.merged_lines(continues_straight)) } else { pieces.clone() };
crate::smlib_sweeps::extrude(plane, &crate::smlib_sweeps::region_pieces(&numbered, &pieces), Some(r), lo, hi - lo, 1.0, None)
})
.collect();
}
let n = plane.normal();
let mut out = Vec::new();
for r in regions {
@@ -313,7 +324,7 @@ fn extrude_with(plane: &Plane, regions: &[Region2], start: f64, end: f64, whole_
/// `t` carries on from `s` in the same straight line: the turn at their meeting point is within
/// rounding of none, measured against both lengths.
fn continues_straight(s: &Seg2, t: &Seg2) -> bool {
pub(crate) fn continues_straight(s: &Seg2, t: &Seg2) -> bool {
let (u, v) = (s.end() - s.start(), t.end() - t.start());
let (lu, lv) = (u.len(), v.len());
lu > 0.0 && lv > 0.0 && s.end().dist(t.start()) <= 1e-9 * (lu + lv) && u.dot(v) > 0.0 && u.cross(v).abs() <= 1e-9 * lu * lv
@@ -432,6 +443,15 @@ fn revolved(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec
let (a, b, c) = (s.start(), s.end(), t.end());
on_axis(a) && on_axis(b) && on_axis(c) && (b - a).dot(c - b) > 0.0
};
if crate::backend()? == crate::Backend::Smlib {
// Partial turns go toward the side the sketch normal points to, as below.
let given = numbered_region(r)?.map_loops(|l| snap_loop(l).merged_lines(axis_run));
let c = plane.to_world(r.centroid());
let radial = (c - o) - axis * (c - o).dot(axis);
let angle = if axis.cross(radial).dot(plane.normal()) < 0.0 { -angle } else { angle };
out.push(crate::smlib_sweeps::revolve(plane, &crate::smlib_sweeps::pieces(given.oriented().loops()), o, axis, angle)?);
continue;
}
let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l).merged_lines(axis_run)));
let r = &given.region();
if angle.abs() >= std::f64::consts::TAU - 1e-9
@@ -597,7 +617,7 @@ pub fn torus(center: Vec3, major: f64, minor: f64) -> Result<Body> {
/// neighbours now meet at one point). Loops of arcs on one circle, and loops of lines, arcs and
/// free curves (spline and conic pieces, such as the outlines of text) where the arcs meet their
/// neighbours tangentially are supported.
fn offset_loop(lp: &Loop2, d: f64) -> Result<Vec<Option<Seg2>>> {
pub(crate) fn offset_loop(lp: &Loop2, d: f64) -> Result<Vec<Option<Seg2>>> {
if d == 0.0 {
return Ok(lp.segs.iter().copied().map(Some).collect());
}
@@ -1296,6 +1316,9 @@ pub fn extrude_tapered_between(plane: &Plane, region: &Region2, from: f64, to: f
return Err(KernelError::Invalid("taper must be between −90° and 90°".into()));
}
region_ok(region)?;
if crate::backend()? == crate::Backend::Smlib {
return smlib_tapered(plane, region, from, to, foot, dir_sign.signum(), taper);
}
let (t, sign) = (taper.tan(), dir_sign.signum());
let (bottom_plane, top_plane) = (plane.offset(from * sign), plane.offset(to * sign));
// A side drawn in collinear pieces slopes as one face over all of them, as a straight
@@ -1358,6 +1381,28 @@ pub fn extrude_tapered_between(plane: &Plane, region: &Region2, from: f64, to: f
})
}
/// [`extrude_tapered_between`] through SMLib's draft extrusion: one loop (SMLib's takes no
/// holes), its section at `from` the profile offset by the taper, no straight foot.
fn smlib_tapered(plane: &Plane, region: &Region2, from: f64, to: f64, foot: f64, sign: f64, taper: f64) -> Result<Body> {
if foot > 0.0 {
return Err(crate::smlib_sweeps::refuse("draft extrusion with a straight foot"));
}
if !region.holes.is_empty() {
return Err(crate::smlib_sweeps::refuse("draft extrusion of a profile with holes (SMLib's takes one loop)"));
}
let outer = oriented(region)?.map_loops(|l| l.merged_lines(continues_straight)).outer;
let bottom = if from == 0.0 || taper == 0.0 {
outer
} else {
let moved = offset_loop(&outer.lp, from * taper.tan())?;
let segs: Option<Vec<Seg2>> = moved.into_iter().collect();
let segs = segs.ok_or_else(|| crate::smlib_sweeps::refuse("draft extrusion that starts before its profile where the taper closes a side"))?;
NumberedLoop { lp: Loop2 { segs }, from: outer.from }
};
let draft = (taper != 0.0).then_some(crate::smlib_sweeps::Draft { taper });
crate::smlib_sweeps::extrude(plane, &crate::smlib_sweeps::pieces([&bottom]), Some(region), from * sign, to - from, sign, draft)
}
/// Revolve 360° a loop that has exactly one line segment on the axis, without degenerate faces.
/// A loop made of a half circle and its diameter on the axis: (centre, radius).
fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> {
@@ -1830,6 +1875,17 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
/// through every section's curve; see [`crate::loft_rails`]), `fit` saying how they reshape the
/// sections between. Without rails, the plain loft.
pub fn loft_with_rails(sections: &[(Plane, Loop2)], rails: &[Vec<Vec3>], fit: RailFit) -> Result<Body> {
if crate::backend()? == crate::Backend::Smlib {
if !rails.is_empty() {
return Err(crate::smlib_sweeps::refuse("loft along guide rails"));
}
let reversed = loft_reversed(sections)?;
let numbered = sections.iter().map(|(p, _)| *p).zip(numbered::number_loops(sections.iter().map(|(_, l)| l))).collect::<Vec<_>>();
let loops = loft_numbered_loops(&numbered, reversed)?;
let first = if reversed { loops.len().saturating_sub(1) } else { 0 };
let pieces: Vec<(Plane, Vec<crate::smlib_sweeps::Piece>)> = loops.iter().map(|(p, l)| (*p, crate::smlib_sweeps::pieces([l]))).collect();
return crate::smlib_sweeps::loft(&pieces, first);
}
let (wires, edge_sources, reversed) = loft_wires(sections)?;
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("loft: sections".into())) };
let mut planes: Vec<Plane> = sections.iter().map(|(p, _)| *p).collect();
@@ -1877,6 +1933,9 @@ pub fn loft_regions(sections: &[(Plane, Region2)]) -> Result<Body> {
if holes == 0 {
return loft(&sections.iter().map(|(p, r)| (*p, r.outer.clone())).collect::<Vec<_>>());
}
if crate::backend()? == crate::Backend::Smlib {
return Err(crate::smlib_sweeps::refuse("loft through sections with holes"));
}
let regions: Vec<Region2> = sections.iter().map(|(_, r)| r.clone()).collect();
let mut numbered = numbered::number_regions(&regions);
let outlines: Vec<_> = sections.iter().map(|(p, _)| *p).zip(numbered.iter().map(|r| r.outer.clone())).collect();
@@ -1983,7 +2042,7 @@ pub(crate) fn loft_wires(sections: &[(Plane, Loop2)]) -> Result<(Vec<mt::Wire>,
Ok((wires, sources, reversed))
}
fn loft_reversed(sections: &[(Plane, Loop2)]) -> Result<bool> {
pub(crate) fn loft_reversed(sections: &[(Plane, Loop2)]) -> Result<bool> {
if sections.len() < 2 || sections.len() > 50 {
return Err(KernelError::Invalid("a loft needs 2…50 sections".into()));
}
@@ -2019,16 +2078,17 @@ fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Re
let mut wires: Vec<mt::Wire> = Vec::new();
// Per section, the profile segments under each of its wire's edges.
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
for (p, r) in loft_section_loops(sections, reversed)? {
for (p, r) in loft_numbered_loops(sections, reversed)? {
wires.push(uniform_wire(&p, &r.lp)?);
edge_sources.push(r.from);
}
Ok((wires, edge_sources))
}
/// The sections of a loft as the wires are built from them: in loft order, oriented alike,
/// re-split to the same number of segments and started where they line up best.
pub(crate) fn loft_section_loops(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Result<Vec<(Plane, NumberedLoop)>> {
/// The loft's sections as the walls are built through them: in canonical order (`reversed`
/// turns them round), each oriented the same way, re-split to the same number of segments and
/// started at the segment that lines up with the lead section's first.
pub(crate) fn loft_numbered_loops(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Result<Vec<(Plane, NumberedLoop)>> {
let n = sections.iter().map(|(_, l)| l.lp.segs.len()).max().unwrap_or(0);
if n == 0 || n > MAX_SEGS {
return Err(KernelError::Invalid("empty section".into()));
@@ -2289,6 +2349,34 @@ pub(crate) fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c))
}
/// [`sweep_with`] through SMLib's curve sweep: the profile turning with the path or only moving
/// along it (orientation parallel), along a path without corners (SMLib's sweep follows smooth
/// paths). Twists, end scales, guide rails and orientation directions have no SMLib sweep.
fn smlib_sweep(plane: &Plane, region: &Region2, path: &[PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
ctl.check()?;
region_ok(region)?;
if path.is_empty() || path.len() > 1000 {
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
}
let translate = match ctl.orientation {
crate::SweepOrientation::Perpendicular => false,
crate::SweepOrientation::Parallel => true,
crate::SweepOrientation::Direction(_) => return Err(crate::smlib_sweeps::refuse("sweep with an orientation direction")),
};
if ctl.twist != 0.0 || ctl.scale != 1.0 {
return Err(crate::smlib_sweeps::refuse("sweep with a twist or an end scale"));
}
if !ctl.rails.is_empty() {
return Err(crate::smlib_sweeps::refuse("sweep along guide rails"));
}
let ends: Vec<(Vec3, Vec3)> =
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) {
return Err(crate::smlib_sweeps::refuse("sweep along a path with corners (SMLib's sweep follows smooth paths)"));
}
crate::smlib_sweeps::sweep(plane, &crate::smlib_sweeps::pieces(oriented(region)?.loops()), path, translate)
}
/// The path as a polyline: its first point, then each segment's points (arcs within 0.01 mm of
/// their chords).
pub fn path_polyline(path: &[PathSeg]) -> Result<Vec<Vec3>> {
@@ -2307,6 +2395,9 @@ pub fn path_polyline(path: &[PathSeg]) -> Result<Vec<Vec3>> {
/// orientation, a twist or an end scale follow the path as a polyline through stations (see
/// [`crate::sweep_path_with`]).
pub fn sweep_with(plane: &Plane, region: &Region2, path: &[PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
if crate::backend()? == crate::Backend::Smlib {
return smlib_sweep(plane, region, path, ctl);
}
if ctl.is_default() {
return sweep(plane, region, path);
}
@@ -2325,6 +2416,9 @@ pub fn sweep_with(plane: &Plane, region: &Region2, path: &[PathSeg], ctl: &crate
/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners
/// is swept as a polyline, mitred at the corners.
pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body> {
if crate::backend()? == crate::Backend::Smlib {
return smlib_sweep(plane, region, path, &crate::SweepControl::default());
}
region_ok(region)?;
if path.is_empty() || path.len() > 1000 {
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
+6
View File
@@ -38,6 +38,9 @@ pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec
if turns > 1.0 && pitch.abs() <= extent {
return Err(KernelError::Invalid(format!("the coil runs into itself: the pitch must exceed the section ({extent:.3} mm)")));
}
if crate::backend()? == crate::Backend::Smlib {
return crate::smlib_sweeps::screw(plane, &crate::smlib_sweeps::pieces([&profile]), axis_origin, k, pitch, turns);
}
// One smooth B-spline wall per profile segment; the ruled walls below are the fallback.
if let Ok(b) = smooth_helix(plane, &profile, axis_origin, k, pitch, turns) {
return Ok(b);
@@ -183,6 +186,9 @@ fn lp_area_path(plane: &Plane, lp: &Loop2, o: Vec3, k: Vec3, turns: f64) -> f64
/// A solid from a planar loop of straight segments to an apex point off its plane (a pyramid).
pub fn loft_to_point(plane: &Plane, lp: &Loop2, apex: Vec3) -> Result<Body> {
if crate::backend()? == crate::Backend::Smlib {
return Err(crate::smlib_sweeps::refuse("loft to a point"));
}
if !apex.is_finite() || lp.segs.is_empty() || lp.segs.len() > 10_000 {
return Err(KernelError::Invalid("loft to a point: bad section or apex".into()));
}
+3
View File
@@ -124,6 +124,9 @@ mod seam_faces;
mod sew;
#[cfg(feature = "smlib")]
mod smlib_boolean;
mod smlib_sweeps;
#[cfg(test)]
mod smlib_sweeps_tests;
mod smooth_edges;
mod sphere_lens;
mod spline_conv;
+6 -6
View File
@@ -20,13 +20,13 @@ use crate::history::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History
const SAMPLES: usize = 24;
/// A face's triangles with their unit normals, and its box.
struct FaceTris {
tris: Vec<([Vec3; 3], Vec3)>,
bounds: Aabb3,
area: f64,
pub(crate) struct FaceTris {
pub(crate) tris: Vec<([Vec3; 3], Vec3)>,
pub(crate) bounds: Aabb3,
pub(crate) area: f64,
}
fn face_tris(m: &Mesh) -> Vec<FaceTris> {
pub(crate) fn face_tris(m: &Mesh) -> Vec<FaceTris> {
let n = m.tri_face.iter().copied().max().map(|x| x as usize + 1).unwrap_or(0);
let mut out: Vec<FaceTris> = (0..n).map(|_| FaceTris { tris: Vec::new(), bounds: Aabb3::EMPTY, area: 0.0 }).collect();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
@@ -40,7 +40,7 @@ fn face_tris(m: &Mesh) -> Vec<FaceTris> {
out
}
fn closest_on_tri(p: Vec3, [a, b, c]: [Vec3; 3]) -> Vec3 {
pub(crate) fn closest_on_tri(p: Vec3, [a, b, c]: [Vec3; 3]) -> Vec3 {
let (ab, ac, ap) = (b - a, c - a, p - a);
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
if d1 <= 0.0 && d2 <= 0.0 {
+471
View File
@@ -0,0 +1,471 @@
//! Extrusions, revolutions, sweeps, lofts and screw sweeps through SMLib ([`crate::backend`]
//! picks them): the profile's segments go to SMLib as 3D curves (lines, circular arcs, Béziers
//! and conics as NURBS), SMLib sweeps them, and the result comes back through its schema 4
//! record as a kernel body. A result SMLib's own check refuses is an error, never a body. An
//! option SMLib has no sweep for is "not supported yet", never truck's sweep in its place.
//!
//! History is in the builders' vocabulary ([`FaceOrigin::Profile`], [`FaceOrigin::Cap`]), found
//! from the result's geometry ([`crate::provenance`] does the same for operations without exact
//! history): a wall comes from the profile segments whose curves its edge at the start runs
//! along (a wall SMLib makes over several tangent segments comes from all of them), and from
//! those whose middles it holds half-way along the sweep where that is known (extrusions,
//! revolutions); the face in the start plane is the start cap, the one in the end plane (or,
//! where the sweep has no end plane, the planar face left over) the end cap.
use solidcraft_geom::{Plane, Region2, Seg2, Vec3};
use crate::body::Body;
use crate::numbered::{NumberedLoop, NumberedRegion};
use crate::{KernelError, Result};
/// A profile segment as SMLib gets it, with the profile segments it stands for.
#[cfg_attr(not(feature = "smlib"), allow(dead_code))]
pub(crate) struct Piece {
pub seg: Seg2,
pub numbers: Vec<usize>,
/// The segment as drawn that names it: itself, or for collinear lines joined into one, the
/// first of them.
pub drawn: Seg2,
}
/// The segments of `loops` as pieces, loop after loop.
pub(crate) fn pieces<'a>(loops: impl IntoIterator<Item = &'a NumberedLoop>) -> Vec<Piece> {
loops.into_iter().flat_map(|l| l.lp.segs.iter().enumerate().map(|(i, s)| Piece { seg: *s, numbers: l.numbers(i).to_vec(), drawn: *s })).collect()
}
/// The pieces of `joined` (`drawn` with collinear lines joined): outer loop, then holes, each
/// joined line named by the first line drawn under it.
pub(crate) fn region_pieces(joined: &NumberedRegion, drawn: &NumberedRegion) -> Vec<Piece> {
let firsts: Vec<Seg2> = drawn.loops().flat_map(|l| l.lp.segs.iter().copied()).collect();
pieces(joined.loops())
.into_iter()
.map(|mut x| {
if x.numbers.len() > 1
&& let Some(first) = firsts.iter().find(|d| d.start() == x.seg.start())
{
x.drawn = *first;
}
x
})
.collect()
}
fn not_supported(what: &str) -> KernelError {
KernelError::Failed(format!("not supported yet: {what}"))
}
/// How an extrusion's walls lean.
#[derive(Clone, Copy, Debug)]
#[cfg_attr(not(feature = "smlib"), allow(dead_code))]
pub(crate) struct Draft {
/// Radians; positive grows the profile (as [`crate::extrude_tapered`] takes it).
pub taper: f64,
}
#[cfg(feature = "smlib")]
pub(crate) use imp::{extrude, loft, revolve, screw, sweep};
#[cfg(not(feature = "smlib"))]
mod stubs {
use super::*;
fn not_built() -> KernelError {
not_supported("this build has no SMLib (feature `smlib`)")
}
pub(crate) fn extrude(_: &Plane, _: &[Piece], _: Option<&Region2>, _: f64, _: f64, _: f64, _: Option<Draft>) -> Result<Body> {
Err(not_built())
}
pub(crate) fn revolve(_: &Plane, _: &[Piece], _: Vec3, _: Vec3, _: f64) -> Result<Body> {
Err(not_built())
}
pub(crate) fn sweep(_: &Plane, _: &[Piece], _: &[crate::PathSeg], _: bool) -> Result<Body> {
Err(not_built())
}
pub(crate) fn loft(_: &[(Plane, Vec<Piece>)], _: usize) -> Result<Body> {
Err(not_built())
}
pub(crate) fn screw(_: &Plane, _: &[Piece], _: Vec3, _: Vec3, _: f64, _: f64) -> Result<Body> {
Err(not_built())
}
}
#[cfg(not(feature = "smlib"))]
pub(crate) use stubs::{extrude, loft, revolve, screw, sweep};
/// Refuses what no SMLib sweep does, before any work.
pub(crate) fn refuse(what: &str) -> KernelError {
not_supported(&format!("SMLib {what}"))
}
#[cfg(feature = "smlib")]
mod imp {
use std::collections::BTreeSet;
use solidcraft_smlib::sweeps::{Curve, SweepFrame};
use solidcraft_smlib::{Brep, CheckLevel};
use solidcraft_geom::Vec2;
use super::*;
use crate::history::FaceProv;
use crate::provenance::{closest_on_tri, face_tris};
use crate::record;
fn failed(what: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib {what}: {e}"))
}
fn p(v: Vec3) -> [f64; 3] {
[v.x, v.y, v.z]
}
/// The segment in 3D on `plane`, as SMLib's curve.
fn curve(plane: &Plane, s: &Seg2) -> Result<Curve> {
let w = |q: Vec2| p(plane.to_world(q));
let n = plane.x.cross(plane.y).normalized().ok_or_else(|| KernelError::Invalid("profile plane".into()))?;
let bezier = |pts: &[Vec2], weights: &[f64]| {
let degree = pts.len().saturating_sub(1);
let points = pts.iter().zip(weights).map(|(q, wt)| {
let [x, y, z] = w(*q);
[x, y, z, *wt]
});
let knots = std::iter::repeat_n(0.0, degree + 1).chain(std::iter::repeat_n(1.0, degree + 1)).collect();
Curve::Nurbs { degree, points: points.collect(), knots }
};
Ok(match *s {
Seg2::Line { a, b } => Curve::Line { start: w(a), end: w(b) },
Seg2::Arc { center, radius, start, sweep } => {
// A full circle re-split for a loft may come out a rounding over one turn.
if !(sweep.abs() <= std::f64::consts::TAU * (1.0 + 1e-9)) {
return Err(KernelError::Invalid("an arc turns more than once".into()));
}
let sweep = sweep.clamp(-std::f64::consts::TAU, std::f64::consts::TAU);
// Clockwise in the plane is counter-clockwise about the other normal, where a
// plane angle t is the angle -t.
let (axis, a0) = if sweep >= 0.0 { (n, start) } else { (-n, -start) };
Curve::Arc { center: w(center), axis: p(axis), reference: p(plane.x), radius, start: a0, end: a0 + sweep.abs() }
}
Seg2::Cubic { p0, p1, p2, p3 } => bezier(&[p0, p1, p2, p3], &[1.0; 4]),
Seg2::Conic { a, apex, b, w: wt } => bezier(&[a, apex, b], &[1.0, wt, 1.0]),
Seg2::Bezier { n: degree, p: pts } => {
let pts = pts.get(..=usize::from(degree)).ok_or_else(|| KernelError::Invalid("Bézier degree".into()))?;
bezier(pts, &vec![1.0; pts.len()])
}
})
}
fn curves(plane: &Plane, pieces: &[Piece]) -> Result<Vec<Curve>> {
pieces.iter().map(|x| curve(plane, &x.seg)).collect()
}
/// The kernel body of an SMLib result SMLib's own check finds valid.
fn body_of(brep: &Brep, what: &str) -> Result<Body> {
let check = brep.check(CheckLevel::All).map_err(|e| failed(what, e))?;
if !check.is_valid() {
return Err(failed(what, format!("SMLib's check refuses the result: {check}")));
}
crate::native::body_from_smlib(brep)
}
/// 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 {
at: Vec3,
piece: usize,
}
fn seeds(plane: &Plane, pieces: &[Piece], to: impl Fn(Vec3) -> Vec3) -> Vec<Seed> {
pieces.iter().enumerate().map(|(piece, x)| Seed { at: to(plane.to_world(x.seg.point_at(0.5))), piece }).collect()
}
/// What a face of a swept body is.
#[derive(Clone, Debug, PartialEq)]
enum Role {
Start,
End,
/// A wall, swept from these pieces.
Wall(BTreeSet<usize>),
Unknown,
}
/// The role of each face of `body` swept from the profile in `start` (see the module notes):
/// `edges` lie on the walls' edges in the start plane, `inside` inside the walls; the face in
/// `end` (or, with `end` unset and `rest_is_end`, a planar face nothing else claims) is the
/// end cap.
fn roles(body: &Body, start: &Plane, end: Option<&Plane>, edges: &[Seed], inside: &[Seed], rest_is_end: bool) -> Result<Vec<Role>> {
let size = body.size().max(1e-9);
let mesh_tol = (size * 2e-4).max(1e-6);
let m = body.tessellate(mesh_tol)?;
let faces = face_tris(&m);
let n = body.face_count();
let flat_tol = size * 1e-5 + 1e-9;
let near = 3.0 * mesh_tol + flat_tol;
let flat = |f: usize, pl: &Plane| {
faces.get(f).is_some_and(|ft| !ft.tris.is_empty() && ft.tris.iter().all(|(t, _)| t.iter().all(|q| pl.height(*q).abs() <= flat_tol)))
};
let planar =
|f: usize| faces.get(f).is_some_and(|ft| ft.tris.first().is_some_and(|(_, n0)| ft.tris.iter().all(|(_, nn)| nn.dot(*n0) > 1.0 - 1e-6)));
// A face in the start plane is the start cap where the profile bounds it (a screw's far
// end after whole turns lies in the same plane, elsewhere).
let touches = |f: usize| {
edges.is_empty()
|| faces.get(f).is_some_and(|ft| edges.iter().any(|s| ft.tris.iter().any(|(t, _)| closest_on_tri(s.at, *t).dist(s.at) <= near)))
};
let start_cap: Vec<bool> = (0..n).map(|f| flat(f, start) && touches(f)).collect();
let end_cap: Vec<bool> = (0..n).map(|f| end.is_some_and(|e| flat(f, e))).collect();
let cap = |f: usize| start_cap.get(f).copied().unwrap_or(false) || end_cap.get(f).copied().unwrap_or(false);
let mut swept: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
for (poly, fs) in m.edges.iter().zip(&m.edge_faces) {
if poly.len() < 2 || !poly.iter().all(|q| start.height(*q).abs() <= flat_tol) {
continue;
}
for s in edges {
if poly.windows(2).any(|w| s.at.dist_to_segment(w[0], w[1]) <= near) {
for f in fs.iter().map(|f| *f as usize).filter(|f| !cap(*f)) {
if let Some(set) = swept.get_mut(f) {
set.insert(s.piece);
}
}
}
}
}
// Inside the walls: the face nearest each point (bounded work).
let tris: usize = faces.iter().map(|f| f.tris.len()).sum();
if tris.saturating_mul(inside.len()) <= 50_000_000 {
for s in inside {
let mut best = (f64::INFINITY, usize::MAX);
for (fi, ft) in faces.iter().enumerate() {
if !ft.bounds.inflated(best.0.min(size)).contains(s.at) {
continue;
}
for (t, _) in &ft.tris {
let d = closest_on_tri(s.at, *t).dist(s.at);
if d < best.0 {
best = (d, fi);
}
}
}
if best.0 <= 5.0 * near
&& !cap(best.1)
&& let Some(set) = swept.get_mut(best.1)
{
set.insert(s.piece);
}
}
}
Ok(swept
.into_iter()
.enumerate()
.map(|(f, set)| {
if start_cap.get(f).copied().unwrap_or(false) {
Role::Start
} else if end_cap.get(f).copied().unwrap_or(false) {
Role::End
} else if !set.is_empty() {
Role::Wall(set)
} else if end.is_none() && rest_is_end && planar(f) {
Role::End
} else {
log::debug!(target: "smlib", "sweep history: face {f} comes from no profile segment");
Role::Unknown
}
})
.collect())
}
/// The history the roles give: caps, and walls from their pieces' profile segments.
fn history(roles: &[Role], pieces: &[Piece]) -> Vec<FaceProv> {
roles
.iter()
.map(|r| match r {
Role::Start => record::cap(false),
Role::End => record::cap(true),
Role::Wall(set) => record::profile(set.iter().filter_map(|i| pieces.get(*i)).flat_map(|x| x.numbers.iter())),
Role::Unknown => record::generated(Vec::new()),
})
.collect()
}
fn with_history(body: Body, roles: &[Role], pieces: &[Piece]) -> Result<Body> {
record::attach(body, &[], Some(history(roles, pieces)), Default::default())
}
/// The profile `pieces` on `plane`, moved `from` along its normal, extruded `distance`
/// along `sign` (±1) times the normal, straight or drafted.
pub(crate) fn extrude(
plane: &Plane,
pieces: &[Piece],
region: Option<&Region2>,
from: f64,
distance: f64,
sign: f64,
draft: Option<Draft>,
) -> Result<Body> {
let base = plane.offset(from);
let n = plane.x.cross(plane.y).normalized().ok_or_else(|| KernelError::Invalid("profile plane".into()))?;
let dir = n * sign.signum();
let c = curves(&base, pieces)?;
// SMLib's draft is positive inward; the kernel's taper grows the profile.
let lean = draft.map_or(0.0, |d| -d.taper);
let brep = Brep::extrude(&c, p(dir), distance, lean, true).map_err(|e| failed("extrude", e))?;
let body = body_of(&brep, "extrude")?;
let edges = seeds(&base, pieces, |q| q);
// Half-way along a straight wall lies over its segment; a drafted one has moved.
let inside = if draft.is_none() { seeds(&base, pieces, |q| q + dir * (distance * 0.5)) } else { Vec::new() };
let top = plane.offset(from + sign.signum() * distance);
let roles = roles(&body, &base, Some(&top), &edges, &inside, false)?;
let sources = (draft.is_none() && sign > 0.0).then(|| extrude_sources(&roles, pieces, region, plane, &base, &top));
let body = body.with_face_sources(sources.map(std::sync::Arc::new));
with_history(body, &roles, pieces)
}
/// The construction sources of a straight extrusion's faces along the normal of `sketch`, as
/// [`crate::extrude`] gives them: each cap the region at its end (both caps found once
/// each), each wall swept from one piece that piece as drawn, a line's with the wall's plane.
fn extrude_sources(
roles: &[Role],
pieces: &[Piece],
region: Option<&Region2>,
sketch: &Plane,
base: &Plane,
top: &Plane,
) -> Vec<Option<crate::FaceSource>> {
let once = |role: &Role| roles.iter().filter(|r| *r == role).count() == 1;
let caps = region.filter(|r| once(&Role::Start) && once(&Role::End) && numbered_segments(r) <= 4096);
roles
.iter()
.map(|role| match role {
Role::Start => caps.map(|r| crate::FaceSource::ExtrudeCap {
high: false,
region: r.clone(),
plane: Plane { origin: base.origin, x: sketch.x, y: -sketch.y },
}),
Role::End => caps.map(|r| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: *top }),
Role::Wall(set) if set.len() == 1 => set.first().and_then(|i| pieces.get(*i)).map(|x| {
let plane = match x.seg {
Seg2::Line { a, b } => (b - a).normalized().and_then(|d| {
let foot = a - d * a.dot(d);
Plane::new(sketch.to_world(foot), sketch.dir_to_world(d), sketch.normal())
}),
_ => None,
};
crate::FaceSource::ExtrudeSide { segment: x.drawn, plane, coalesced: false }
}),
_ => None,
})
.collect()
}
fn numbered_segments(r: &Region2) -> usize {
r.outer.segs.len() + r.holes.iter().map(|h| h.segs.len()).sum::<usize>()
}
/// `v` turned by `a` right-handed about the unit `k` (Rodrigues).
fn turned(v: Vec3, k: Vec3, a: f64) -> Vec3 {
v * a.cos() + k.cross(v) * a.sin() + k * (k.dot(v) * (1.0 - a.cos()))
}
/// The profile `pieces` on `plane` turned `angle` radians (signed, right-handed about `axis`)
/// about the axis through `origin` (both in the plane). Segments on the axis sweep nothing.
pub(crate) fn revolve(plane: &Plane, pieces: &[Piece], origin: Vec3, axis: Vec3, angle: f64) -> Result<Body> {
let k = axis.normalized().ok_or_else(|| KernelError::Invalid("revolve axis".into()))?;
let (k, turn) = if angle < 0.0 { (-k, -angle) } else { (k, angle) };
let turn = turn.min(std::f64::consts::TAU);
let c = curves(plane, pieces)?;
let brep = Brep::revolve(&c, p(origin), p(k), turn, true).map_err(|e| failed("revolve", e))?;
let body = body_of(&brep, "revolve")?;
let size = body.size().max(1e-9);
let off_axis = |q: Vec3| {
let w = q - origin;
(w - k * w.dot(k)).len() > size * 1e-7
};
let walls: Vec<Piece> = pieces
.iter()
.filter(|x| [0.0, 0.5, 1.0].iter().any(|t| off_axis(plane.to_world(x.seg.point_at(*t)))))
.map(|x| Piece { seg: x.seg, numbers: x.numbers.clone(), drawn: x.drawn })
.collect();
let about = |q: Vec3, a: f64| origin + turned(q - origin, k, a);
let full = turn >= std::f64::consts::TAU - 1e-9;
let edges = seeds(plane, &walls, |q| q);
let inside = seeds(plane, &walls, |q| about(q, turn * 0.5));
let end = (!full).then(|| Plane { origin: about(plane.origin, turn), x: turned(plane.x, k, turn), y: turned(plane.y, k, turn) });
// A full turn has no caps, and its profile plane holds seams only.
let roles = if full {
roles(&body, &plane.offset(size * 10.0), None, &[], &inside, false)?
} else {
roles(&body, plane, end.as_ref(), &edges, &inside, false)?
};
with_history(body, &roles, &walls)
}
fn path_curve(s: &crate::PathSeg) -> Result<Curve> {
Ok(match *s {
crate::PathSeg::Line { a, b } => Curve::Line { start: p(a), end: p(b) },
crate::PathSeg::Arc { a, center, axis, angle } => {
let k = axis.normalized().ok_or_else(|| KernelError::Invalid("path arc axis".into()))?;
let (k, turn) = if angle < 0.0 { (-k, -angle) } else { (k, angle) };
let r = a - center;
let radius = r.len();
let reference = r.normalized().ok_or_else(|| KernelError::Invalid("path arc radius".into()))?;
Curve::Arc { center: p(center), axis: p(k), reference: p(reference), radius, start: 0.0, end: turn }
}
})
}
/// The profile `pieces` on `plane` swept along `path`, turning with it, or only moving along
/// it when `translate`.
pub(crate) fn sweep(plane: &Plane, pieces: &[Piece], path: &[crate::PathSeg], translate: bool) -> Result<Body> {
let c = curves(plane, pieces)?;
let route = path.iter().map(path_curve).collect::<Result<Vec<_>>>()?;
let frame = if translate { SweepFrame::Translate } else { SweepFrame::Follow };
let brep = Brep::sweep(&c, &route, frame, true).map_err(|e| failed("sweep", e))?;
let body = body_of(&brep, "sweep")?;
let roles = roles(&body, plane, None, &seeds(plane, pieces, |q| q), &[], true)?;
with_history(body, &roles, pieces)
}
/// A loft through `sections` (each a closed loop of pieces, all re-split to the same count
/// and lined up), `first` the index of the section given first (its cap is the start cap).
pub(crate) fn loft(sections: &[(Plane, Vec<Piece>)], first: usize) -> Result<Body> {
let curves: Vec<Vec<Curve>> = sections.iter().map(|(pl, ps)| curves(pl, ps)).collect::<Result<_>>()?;
// Ruled between two sections, else smooth through them (cubic from four).
let degree = match sections.len() {
0..=2 => 1,
3 => 2,
_ => 3,
};
let brep = Brep::loft(&curves, degree, true).map_err(|e| failed("loft", e))?;
let body = body_of(&brep, "loft")?;
let (Some((start, lead)), Some(last)) = (sections.get(first), sections.get(if first == 0 { sections.len().saturating_sub(1) } else { 0 }))
else {
return Err(KernelError::Invalid("loft sections".into()));
};
// Each wall comes from its column's segments in every section.
let columns: Vec<Piece> = lead
.iter()
.enumerate()
.map(|(i, x)| {
let mut all: BTreeSet<usize> = BTreeSet::new();
for (_, ps) in sections {
if let Some(q) = ps.get(i) {
all.extend(q.numbers.iter().copied());
}
}
Piece { seg: x.seg, numbers: all.into_iter().collect(), drawn: x.drawn }
})
.collect();
let roles = roles(&body, start, Some(&last.0), &seeds(start, &columns, |q| q), &[], false)?;
with_history(body, &roles, &columns)
}
/// The profile `pieces` on `plane` carried by a screw motion about the axis through `origin`
/// along `axis`: `turns` right-handed turns, rising `pitch` per turn.
pub(crate) fn screw(plane: &Plane, pieces: &[Piece], origin: Vec3, axis: Vec3, pitch: f64, turns: f64) -> Result<Body> {
let c = curves(plane, pieces)?;
// Walls within a ten-thousandth of the pitch of the true screw surfaces.
let tolerance = (pitch.abs() * 1e-4).clamp(1e-6, 1e-2);
let brep = Brep::screw_sweep(&c, p(origin), p(axis), pitch, turns, tolerance).map_err(|e| failed("screw sweep", e))?;
let body = body_of(&brep, "screw sweep")?;
let roles = roles(&body, plane, None, &seeds(plane, pieces, |q| q), &[], true)?;
with_history(body, &roles, pieces)
}
}
+119
View File
@@ -0,0 +1,119 @@
//! The sweeps through SMLib ([`crate::smlib_sweeps`]): analytic volumes, and the history and
//! construction sources the builders' vocabulary gives each face.
#![cfg(feature = "smlib")]
use std::f64::consts::{PI, TAU};
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use crate::FaceSource;
use crate::body::Body;
use crate::history::{FaceOrigin, History};
use crate::numbered::number_regions;
use crate::smlib_sweeps::{self, Draft, Piece};
fn region(outer: Loop2) -> Region2 {
Region2 { outer, holes: Vec::new() }
}
fn pieces_of(r: &Region2) -> Vec<Piece> {
let n = number_regions(std::slice::from_ref(r)).pop().unwrap().oriented();
smlib_sweeps::pieces(n.loops())
}
/// Measured on the body's mesh: curved faces come out within about 1e-3.
fn volume(b: &Body) -> f64 {
crate::measure(b).unwrap().volume
}
fn close(got: f64, want: f64, rel: f64, what: &str) {
assert!((got - want).abs() <= rel * want.abs(), "{what}: {got} vs analytic {want}");
}
/// Each face's origins, sorted by their debug text (the faces' order is SMLib's).
fn origins(b: &Body) -> Vec<String> {
let h: &History = b.history().expect("a history");
let mut v: Vec<String> = h.faces.iter().map(|f| format!("{:?}", f.origins)).collect();
v.sort();
v
}
fn profile(k: usize) -> String {
format!("{:?}", vec![FaceOrigin::Profile { segment: k }])
}
fn cap(end: bool) -> String {
format!("{:?}", vec![FaceOrigin::Cap { end }])
}
#[test]
fn an_extrusion_names_its_caps_and_walls_and_keeps_their_sources() {
let r = region(Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 6.0), Vec2::new(0.0, 6.0)]));
let b = smlib_sweeps::extrude(&Plane::XY, &pieces_of(&r), Some(&r), 2.0, 5.0, 1.0, None).unwrap();
close(volume(&b), 300.0, 1e-9, "prism");
let mut want = vec![cap(false), cap(true), profile(0), profile(1), profile(2), profile(3)];
want.sort();
assert_eq!(origins(&b), want);
let sources = b.face_sources().expect("sources");
let caps = sources.iter().filter(|s| matches!(s, Some(FaceSource::ExtrudeCap { .. }))).count();
let sides = sources.iter().filter(|s| matches!(s, Some(FaceSource::ExtrudeSide { plane: Some(_), .. }))).count();
assert_eq!((caps, sides), (2, 4));
// A drafted disc: a frustum, its wall the circle's.
let disc = region(Loop2::circle(Vec2::ZERO, 5.0));
let taper = -5f64.to_radians();
let b = smlib_sweeps::extrude(&Plane::XY, &pieces_of(&disc), Some(&disc), 0.0, 4.0, 1.0, Some(Draft { taper })).unwrap();
let r2 = 5.0 + 4.0 * taper.tan();
close(volume(&b), PI * 4.0 / 3.0 * (25.0 + r2 * r2 + 5.0 * r2), 1e-3, "drafted disc");
assert!(origins(&b).iter().filter(|o| o.contains("Profile")).count() >= 1);
}
#[test]
fn a_revolution_names_its_walls_by_segment_and_skips_the_axis() {
// A half disc on the y axis of the xy plane: a sphere, every wall from the arc.
let half = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::new(0.0, -5.0), b: Vec2::new(0.0, 5.0) },
Seg2::Arc { center: Vec2::ZERO, radius: 5.0, start: PI / 2.0, sweep: -PI },
],
};
let r = region(half);
let b = smlib_sweeps::revolve(&Plane::XY, &pieces_of(&r), Vec3::ZERO, Vec3::Y, TAU).unwrap();
close(volume(&b), 4.0 / 3.0 * PI * 125.0, 1e-3, "sphere (measured on a mesh)");
assert!(origins(&b).iter().all(|o| *o == profile(1)), "{:?}", origins(&b));
// A quarter turn of a square off the axis: two caps and four walls.
let sq = region(Loop2::polygon(&[Vec2::new(6.0, 0.0), Vec2::new(8.0, 0.0), Vec2::new(8.0, 2.0), Vec2::new(6.0, 2.0)]));
let b = smlib_sweeps::revolve(&Plane::XY, &pieces_of(&sq), Vec3::ZERO, Vec3::Y, PI / 2.0).unwrap();
close(volume(&b), PI / 2.0 * 7.0 * 4.0, 1e-3, "quarter ring");
let mut want = vec![cap(false), cap(true), profile(0), profile(1), profile(2), profile(3)];
want.sort();
assert_eq!(origins(&b), want);
}
#[test]
fn a_loft_and_a_screw_sweep_name_their_ends() {
let square = |z: f64| {
let r = region(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)]));
(Plane::XY.offset(z), pieces_of(&r))
};
let b = smlib_sweeps::loft(&[square(0.0), square(8.0)], 0).unwrap();
close(volume(&b), 800.0, 1e-6, "loft");
let o = origins(&b);
assert!(o.contains(&cap(false)) && o.contains(&cap(true)) && o.iter().filter(|x| x.contains("Profile")).count() == 4, "{o:?}");
// A 0.4 mm square tooth at radius 4 in the xz plane, three turns of pitch 1 about z.
let xz = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
let tooth = region(Loop2::polygon(&[Vec2::new(4.0, 0.0), Vec2::new(4.4, 0.0), Vec2::new(4.4, 0.4), Vec2::new(4.0, 0.4)]));
let b = smlib_sweeps::screw(&xz, &pieces_of(&tooth), Vec3::ZERO, Vec3::Z, 1.0, 3.0).unwrap();
close(volume(&b), 0.16 * TAU * 4.2 * 3.0, 1e-3, "screw");
let o = origins(&b);
assert!(o.contains(&cap(false)) && o.contains(&cap(true)), "{o:?}");
}
#[test]
fn a_sweep_keeps_a_profile_off_its_path_where_it_lies() {
// A disc 5 mm beside a quarter-arc path of radius 20 about (-20, 0, 0): Pappus at 25.
let plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y };
let disc = region(Loop2::circle(Vec2::new(5.0, 0.0), 2.0));
let path = [crate::PathSeg::Arc { a: Vec3::ZERO, center: Vec3::new(-20.0, 0.0, 0.0), axis: -Vec3::Y, angle: PI / 2.0 }];
let b = smlib_sweeps::sweep(&plane, &pieces_of(&disc), &path, false).unwrap();
close(volume(&b), PI * 4.0 * (PI / 2.0 * 25.0), 1e-3, "bent tube");
}
+3
View File
@@ -523,6 +523,9 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body
/// [`sweep_path`] with sweep options: orientation, twist and end scale.
pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &SweepControl) -> Result<Body> {
ctl.check()?;
if crate::backend()? == crate::Backend::Smlib {
return Err(crate::smlib_sweeps::refuse("sweep along a path of points (a 3D sketch curve)"));
}
if region.area().abs() < 1e-9 {
return Err(KernelError::Invalid("profile has no area".into()));
}