diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 6f3ce61d91..345faf6d66 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -156,7 +156,7 @@ fn attach_profile(wires: &[mt::Wire]) -> Result { 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 { /// 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>> { +pub(crate) fn offset_loop(lp: &Loop2, d: f64) -> Result>> { 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 { + 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> = 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 { /// 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], fit: RailFit) -> Result { + 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::>(); + let loops = loft_numbered_loops(&numbered, reversed)?; + let first = if reversed { loops.len().saturating_sub(1) } else { 0 }; + let pieces: Vec<(Plane, Vec)> = 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 = sections.iter().map(|(p, _)| *p).collect(); @@ -1877,6 +1933,9 @@ pub fn loft_regions(sections: &[(Plane, Region2)]) -> Result { if holes == 0 { return loft(§ions.iter().map(|(p, r)| (*p, r.outer.clone())).collect::>()); } + if crate::backend()? == crate::Backend::Smlib { + return Err(crate::smlib_sweeps::refuse("loft through sections with holes")); + } let regions: Vec = sections.iter().map(|(_, r)| r.clone()).collect(); let mut numbered = numbered::number_regions(®ions); 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, Ok((wires, sources, reversed)) } -fn loft_reversed(sections: &[(Plane, Loop2)]) -> Result { +pub(crate) fn loft_reversed(sections: &[(Plane, Loop2)]) -> Result { 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 = Vec::new(); // Per section, the profile segments under each of its wire's edges. let mut edge_sources: Vec>> = 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> { +/// 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> { 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 { + 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::>().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> { @@ -2307,6 +2395,9 @@ pub fn path_polyline(path: &[PathSeg]) -> Result> { /// 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 { + 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 { + 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())); diff --git a/crates/kernel/src/helix.rs b/crates/kernel/src/helix.rs index 37b4a52c9b..d4169ebf47 100644 --- a/crates/kernel/src/helix.rs +++ b/crates/kernel/src/helix.rs @@ -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 { + 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())); } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 06605b5986..c97d7d51c1 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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; diff --git a/crates/kernel/src/provenance.rs b/crates/kernel/src/provenance.rs index 844a203d11..a02c0cf458 100644 --- a/crates/kernel/src/provenance.rs +++ b/crates/kernel/src/provenance.rs @@ -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 { +pub(crate) fn face_tris(m: &Mesh) -> Vec { let n = m.tri_face.iter().copied().max().map(|x| x as usize + 1).unwrap_or(0); let mut out: Vec = (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 { 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 { diff --git a/crates/kernel/src/smlib_sweeps.rs b/crates/kernel/src/smlib_sweeps.rs new file mode 100644 index 0000000000..83782aa789 --- /dev/null +++ b/crates/kernel/src/smlib_sweeps.rs @@ -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, + /// 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) -> Vec { + 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 { + let firsts: Vec = 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) -> Result { + Err(not_built()) + } + pub(crate) fn revolve(_: &Plane, _: &[Piece], _: Vec3, _: Vec3, _: f64) -> Result { + Err(not_built()) + } + pub(crate) fn sweep(_: &Plane, _: &[Piece], _: &[crate::PathSeg], _: bool) -> Result { + Err(not_built()) + } + pub(crate) fn loft(_: &[(Plane, Vec)], _: usize) -> Result { + Err(not_built()) + } + pub(crate) fn screw(_: &Plane, _: &[Piece], _: Vec3, _: Vec3, _: f64, _: f64) -> Result { + 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 { + 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> { + 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 { + 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 { + 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), + 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> { + 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 = (0..n).map(|f| flat(f, start) && touches(f)).collect(); + let end_cap: Vec = (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> = 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 { + 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 { + 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, + ) -> Result { + 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> { + 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::() + } + + /// `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 { + 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 = 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 { + 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 { + let c = curves(plane, pieces)?; + let route = path.iter().map(path_curve).collect::>>()?; + 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)], first: usize) -> Result { + let curves: Vec> = sections.iter().map(|(pl, ps)| curves(pl, ps)).collect::>()?; + // 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 = lead + .iter() + .enumerate() + .map(|(i, x)| { + let mut all: BTreeSet = 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 { + 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) + } +} diff --git a/crates/kernel/src/smlib_sweeps_tests.rs b/crates/kernel/src/smlib_sweeps_tests.rs new file mode 100644 index 0000000000..52bb97467d --- /dev/null +++ b/crates/kernel/src/smlib_sweeps_tests.rs @@ -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 { + 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 { + let h: &History = b.history().expect("a history"); + let mut v: Vec = 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"); +} diff --git a/crates/kernel/src/sweep_path.rs b/crates/kernel/src/sweep_path.rs index 08fb3a95bb..b0720affc9 100644 --- a/crates/kernel/src/sweep_path.rs +++ b/crates/kernel/src/sweep_path.rs @@ -523,6 +523,9 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result Result { 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())); }