From 520d934fe4fbe47bd26611b8b0587d33633ea4de Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:09:14 -0400 Subject: [PATCH] kernel: SMLib sweeps that twist, scale or follow rails, railed lofts, and paths of points On SMLib a sweep with a twist, an end scale, an orientation direction or guide rails, and a loft with guide rails, were refused. They now place the profile at stations by the laws the kernel already defines for them (sweep_path::controlled_stations / railed_stations, and the loft_rails law: plain loft between the sections moved onto the rails), each section the profile's exact image, and SMLib skins the walls through them (Brep::sweep_stations, Brep::loft). The path is sampled exactly on its lines and arcs, a station per degree of turn: SMLib's skin interpolates with free ends, its error falling as the square of the step (a quarter-twisted square is 2e-7 off in volume at 1.4 degrees, 2e-6 at 2.8). Round a closed path the last section has to land on the first (closing_offset, as for truck) and the ends are joined. A railed loft needs each section's curves to pair with the others' (same kind), else "not supported yet". A path of points (3D sketch curves) was refused outright on SMLib; a line or a polyline with corners is now swept as its lines (the corner refusal still applies), and only a smooth polyline of more points (a drawn 3D spline) stays "not supported yet". History in the builders' vocabulary: caps from the start and end section planes, walls from the profile segments (round a closed path, from points inside the walls at the middle station). Kernel cases now passing on SMLib: sweeps/1269-sweep-twisted-square- ninety-degrees, 2005-closed-sweep-twisted-cut-inside-a-torus, 2005-closed-sweep-twisted-new-body, 201-loft-one-guide-rail-cone- frustum, 2862-three-pipes-meeting-at-one-point, 2891-twisted-sweep- cut-through-a-tube, 771-sweep-end-scale-cone-frustum, 771-sweep-one- and -two-guide-rail(s)-cone-frustum. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt --- crates/kernel/src/build.rs | 30 +- crates/kernel/src/loft_rails.rs | 12 +- crates/kernel/src/smlib_sweeps.rs | 404 +++++++++++++++++++++++- crates/kernel/src/smlib_sweeps_tests.rs | 47 +++ crates/kernel/src/sweep_path.rs | 40 ++- 5 files changed, 498 insertions(+), 35 deletions(-) diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 345faf6d66..001e8d0a40 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -1876,14 +1876,14 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result { /// 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(); + if !rails.is_empty() { + return crate::smlib_sweeps::loft_rails(&pieces, first, rails, fit); + } return crate::smlib_sweeps::loft(&pieces, first); } let (wires, edge_sources, reversed) = loft_wires(sections)?; @@ -2349,31 +2349,27 @@ 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. +/// [`sweep_with`] through SMLib, along a path without corners (SMLib's sweeps follow smooth +/// paths): its curve sweep for the profile turning with the path or only moving along it +/// (orientation parallel); a twist, an end scale, guide rails or an orientation direction place +/// the profile at stations along the path ([`crate::smlib_sweeps::sweep_controlled`]). 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)")); } + let translate = match ctl.orientation { + _ if ctl.varies() => return crate::smlib_sweeps::sweep_controlled(plane, &oriented(region)?, path, ctl), + crate::SweepOrientation::Perpendicular => false, + crate::SweepOrientation::Parallel => true, + crate::SweepOrientation::Direction(_) => return crate::smlib_sweeps::sweep_controlled(plane, &oriented(region)?, path, ctl), + }; crate::smlib_sweeps::sweep(plane, &crate::smlib_sweeps::pieces(oriented(region)?.loops()), path, translate) } diff --git a/crates/kernel/src/loft_rails.rs b/crates/kernel/src/loft_rails.rs index 72acd5e6e6..84200ce63d 100644 --- a/crates/kernel/src/loft_rails.rs +++ b/crates/kernel/src/loft_rails.rs @@ -18,9 +18,9 @@ use crate::sweep_path::RailFit; use crate::{KernelError, Result}; /// Most wires a railed loft is skinned through, sections and stations together. -const MAX_WIRES: usize = 33; +pub(crate) const MAX_WIRES: usize = 33; /// Most stations between two sections. -const MAX_PER_SPAN: usize = 15; +pub(crate) const MAX_PER_SPAN: usize = 15; type Curve = mt::BSplineCurve; @@ -42,14 +42,14 @@ struct Rail { } /// An affine move: `x` goes to `to + m·(x − from)` (`m` by columns). -struct Affine { +pub(crate) struct Affine { from: Vec3, to: Vec3, m: [Vec3; 3], } impl Affine { - fn apply(&self, x: Vec3) -> Vec3 { + pub(crate) fn apply(&self, x: Vec3) -> Vec3 { let d = x - self.from; self.to + self.m[0] * d.x + self.m[1] * d.y + self.m[2] * d.z } @@ -259,7 +259,7 @@ fn nearest_on(section: &[Curve], p: Vec3) -> Option<(f64, Vec3, f64)> { /// A wire parameter `f` of the way from `g0` to `g1`; round a closed wire of `n` edges the /// shorter way. -fn interpolate_g(g0: f64, g1: f64, f: f64, n: usize, closed: bool) -> f64 { +pub(crate) fn interpolate_g(g0: f64, g1: f64, f: f64, n: usize, closed: bool) -> f64 { let n = n as f64; if !closed { return (g0 + f * (g1 - g0)).clamp(0.0, n); @@ -294,7 +294,7 @@ fn centroid(ctrl: &[Vec]) -> Vec3 { /// rail's point), the station's middle `middle`, the loft's direction there `dir` (unit); `size` /// the loft's size. One rail: turned about `dir` and sized about the middle, shifted along `dir`; /// two: carried with the first, turned and sized with the line between them, leaning along `dir`. -fn station_map(pairs: &[(Vec3, Vec3)], middle: Vec3, dir: Vec3, fit: RailFit, size: f64) -> Result { +pub(crate) fn station_map(pairs: &[(Vec3, Vec3)], middle: Vec3, dir: Vec3, fit: RailFit, size: f64) -> Result { let (from, to, a, r, two) = match pairs { [(p, q)] => (middle, middle, *p - middle, *q - middle, false), [(p1, q1), (p2, q2)] => (*p1, *q1, *p2 - *p1, *q2 - *q1, true), diff --git a/crates/kernel/src/smlib_sweeps.rs b/crates/kernel/src/smlib_sweeps.rs index 83782aa789..9e19b3202c 100644 --- a/crates/kernel/src/smlib_sweeps.rs +++ b/crates/kernel/src/smlib_sweeps.rs @@ -4,6 +4,11 @@ //! 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. //! +//! A sweep that twists, scales, keeps an orientation direction or follows guide rails, and a +//! loft that follows guide rails, place the profile at stations by the laws the kernel's own +//! sweeps and lofts define ([`crate::sweep_path`], [`crate::loft_rails`]); each section is the +//! profile's exact image, and SMLib skins the walls through them. +//! //! 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 @@ -63,7 +68,7 @@ pub(crate) struct Draft { } #[cfg(feature = "smlib")] -pub(crate) use imp::{extrude, loft, revolve, screw, sweep}; +pub(crate) use imp::{extrude, loft, loft_rails, revolve, screw, sweep, sweep_controlled}; #[cfg(not(feature = "smlib"))] mod stubs { @@ -87,9 +92,15 @@ mod stubs { pub(crate) fn screw(_: &Plane, _: &[Piece], _: Vec3, _: Vec3, _: f64, _: f64) -> Result { Err(not_built()) } + pub(crate) fn sweep_controlled(_: &Plane, _: &NumberedRegion, _: &[crate::PathSeg], _: &crate::SweepControl) -> Result { + Err(not_built()) + } + pub(crate) fn loft_rails(_: &[(Plane, Vec)], _: usize, _: &[Vec], _: crate::RailFit) -> Result { + Err(not_built()) + } } #[cfg(not(feature = "smlib"))] -pub(crate) use stubs::{extrude, loft, revolve, screw, sweep}; +pub(crate) use stubs::{extrude, loft, loft_rails, revolve, screw, sweep, sweep_controlled}; /// Refuses what no SMLib sweep does, before any work. pub(crate) fn refuse(what: &str) -> KernelError { @@ -100,7 +111,7 @@ pub(crate) fn refuse(what: &str) -> KernelError { mod imp { use std::collections::BTreeSet; - use solidcraft_smlib::sweeps::{Curve, SweepFrame}; + use solidcraft_smlib::sweeps::{Affine, Curve, Ends, SweepFrame}; use solidcraft_smlib::{Brep, CheckLevel}; use solidcraft_geom::Vec2; @@ -423,6 +434,110 @@ mod imp { with_history(body, &roles, pieces) } + /// The length of a path segment. + fn seg_len(s: &crate::PathSeg) -> f64 { + match *s { + crate::PathSeg::Line { a, b } => a.dist(b), + crate::PathSeg::Arc { a, center, angle, .. } => angle.abs() * a.dist(center), + } + } + + /// The point `f` (0…1) of the way along a path segment, and the unit direction of travel there. + fn seg_at(s: &crate::PathSeg, f: f64) -> Option<(Vec3, Vec3)> { + match *s { + crate::PathSeg::Line { a, b } => Some((a.lerp(b, f), (b - a).normalized()?)), + crate::PathSeg::Arc { a, center, axis, angle } => { + let k = axis.normalized()?; + let q = crate::build::rotate_about(a, center, k, angle * f); + Some((q, (k.cross(q - center) * angle.signum()).normalized()?)) + } + } + } + + /// `n + 1` points evenly spaced by length along `path` (exact on its lines and arcs), with the + /// unit directions of travel there. + fn path_samples(path: &[crate::PathSeg], n: usize) -> Result<(Vec, Vec)> { + let lens: Vec = path.iter().map(seg_len).collect(); + let total: f64 = lens.iter().sum(); + if !(total > 1e-9 && total.is_finite()) || n == 0 { + return Err(KernelError::Invalid("the sweep path has no length".into())); + } + let bad = || KernelError::Invalid("a path segment has no direction".into()); + let (mut pts, mut tangents) = (Vec::with_capacity(n + 1), Vec::with_capacity(n + 1)); + let (mut k, mut before) = (0usize, 0.0f64); + for i in 0..=n { + let at = total * i as f64 / n as f64; + while k + 1 < path.len() && lens.get(k).is_some_and(|l| before + l < at) { + before += lens.get(k).copied().unwrap_or(0.0); + k += 1; + } + let (seg, len) = path.get(k).zip(lens.get(k)).ok_or_else(bad)?; + let f = if *len > 0.0 { ((at - before) / len).clamp(0.0, 1.0) } else { 0.0 }; + let (q, t) = seg_at(seg, f).ok_or_else(bad)?; + pts.push(q); + tangents.push(t); + } + Ok((pts, tangents)) + } + + /// Fewest and most stations of a sweep that twists, scales or follows rails. + const MIN_STATIONS: usize = 16; + const MAX_STATIONS: usize = 2048; + + /// The profile `region` on `plane` swept along the smooth `path` the way `ctl` asks: twisted, + /// scaled, kept at an orientation or guided by rails. The profile is placed at stations along + /// the path by [`crate::sweep_path`]'s laws (the same the polyline sweep uses), each section + /// exactly the profile's image, and SMLib skins the walls through them. Round a closed path the + /// last section has to land on the first, and the ends are joined. + pub(crate) fn sweep_controlled(plane: &Plane, region: &NumberedRegion, path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result { + let pieces = pieces(region.loops()); + let c = curves(plane, &pieces)?; + let start = match path.first() { + Some(crate::PathSeg::Line { a, .. } | crate::PathSeg::Arc { a, .. }) => *a, + None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())), + }; + // Each point of the profile turns with the path and the twist, and SMLib's skin is a cubic + // interpolation through the stations with free ends, whose error near the ends falls only + // as the square of the step: a square of reach 7.1 twisted a quarter turn over 40 mm is + // 2e-7 off in volume at 1.4 degree steps and 2e-6 at 2.8 (measured against V = A L). A + // station per degree of turn. + let turn: f64 = path.iter().map(|s| if let crate::PathSeg::Arc { angle, .. } = s { angle.abs() } else { 0.0 }).sum::() + ctl.twist.abs(); + let step = std::f64::consts::PI / 180.0; + let rail_points = ctl.rails.iter().map(Vec::len).max().unwrap_or(0); + let n = ((turn / step).ceil() as usize).max(rail_points.saturating_sub(1)).clamp(MIN_STATIONS, MAX_STATIONS); + let (pts, tangents) = path_samples(path, n)?; + let st = crate::sweep_path::controlled_stations(plane, &pts, &tangents, &[], ctl)?; + let (Some(first), Some(last)) = (st.first(), st.last()) else { return Err(KernelError::Invalid("sweep path".into())) }; + let s0 = first.at; + let size = pts.windows(2).map(|w| w[0].dist(w[1])).sum::() + region.outer.lp.signed_area().abs().sqrt(); + let closed = pts.last().is_some_and(|e| e.dist(start) <= 1e-7 * (1.0 + size)); + if closed { + for l in region.loops() { + crate::sweep_path::closing_offset(plane, l, &st)?; + } + } + let maps: Vec = st + .iter() + .map(|s| { + let [a, b, c] = s.m; + Affine { m: [p(a), p(b), p(c)], t: p(s.at - s.apply(s0)) } + }) + .collect(); + let brep = Brep::sweep_stations(&c, &maps, if closed { Ends::Joined } else { Ends::Capped }).map_err(|e| failed("sweep", e))?; + let body = body_of(&brep, "sweep")?; + let mid = st.get(st.len() / 2).copied().unwrap_or(*last); + let inside = seeds(plane, &pieces, |q| mid.at + mid.apply(q - s0)); + let roles = if closed { + // Round a closed path there are no caps, and the start holds seams only. + roles(&body, &plane.offset(size * 10.0), None, &[], &inside, false)? + } else { + let end = Plane::new(last.at + last.apply(plane.origin - s0), last.apply(plane.x), last.apply(plane.y)) + .ok_or_else(|| KernelError::Invalid("the sweep's end section is degenerate".into()))?; + roles(&body, plane, Some(&end), &seeds(plane, &pieces, |q| q), &inside, false)? + }; + 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 { @@ -433,7 +548,13 @@ mod imp { 3 => 2, _ => 3, }; - let brep = Brep::loft(&curves, degree, true).map_err(|e| failed("loft", e))?; + loft_through(sections, first, &curves, degree) + } + + /// The loft skinned through `curves` (the sections', or with stations between them), `degree` + /// across them; `sections` and `first` as [`loft`] takes them name the faces. + fn loft_through(sections: &[(Plane, Vec)], first: usize, curves: &[Vec], degree: u32) -> Result { + 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 { @@ -457,6 +578,281 @@ mod imp { with_history(body, &roles, &columns) } + /// A curve as a NURBS net: degree, control points (x, y, z) and weights, clamped knots. A line + /// is of degree 1; an arc is rational quadratic, one span per quarter turn or less. + struct Net { + degree: usize, + points: Vec, + weights: Vec, + knots: Vec, + } + + impl Net { + fn of(c: &Curve) -> Option { + let v = |q: [f64; 3]| Vec3::new(q[0], q[1], q[2]); + Some(match c { + Curve::Line { start, end } => { + Net { degree: 1, points: vec![v(*start), v(*end)], weights: vec![1.0; 2], knots: vec![0.0, 0.0, 1.0, 1.0] } + } + Curve::Arc { center, axis, reference, radius, start, end } => { + let (c, k, x) = (v(*center), v(*axis), v(*reference)); + let y = k.cross(x); + let sweep = end - start; + let m = (sweep / (std::f64::consts::PI / 2.0) - 1e-9).ceil().max(1.0) as usize; + let d = sweep / m as f64; + let at = |a: f64, r: f64| c + (x * a.cos() + y * a.sin()) * r; + let (mut points, mut weights, mut knots) = (vec![at(*start, *radius)], vec![1.0], vec![0.0; 3]); + for j in 0..m { + let a0 = start + d * j as f64; + points.push(at(a0 + d / 2.0, radius / (d / 2.0).cos())); + weights.push((d / 2.0).cos()); + points.push(at(a0 + d, *radius)); + weights.push(1.0); + let u = (j + 1) as f64 / m as f64; + knots.extend(if j + 1 < m { vec![u, u] } else { vec![1.0; 3] }); + } + Net { degree: 2, points, weights, knots } + } + Curve::Nurbs { degree, points, knots } => Net { + degree: *degree, + points: points.iter().map(|q| Vec3::new(q[0], q[1], q[2])).collect(), + weights: points.iter().map(|q| q[3]).collect(), + knots: knots.clone(), + }, + }) + } + + /// The same curve with other control points. + fn with(&self, points: Vec) -> Net { + Net { degree: self.degree, points, weights: self.weights.clone(), knots: self.knots.clone() } + } + + /// Whether `o` has the same degree, knots and weights (its control points pair up). + fn pairs_with(&self, o: &Net) -> bool { + let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1e-9 * (1.0 + x.abs())); + self.degree == o.degree && self.points.len() == o.points.len() && near(&self.knots, &o.knots) && near(&self.weights, &o.weights) + } + + fn curve(&self) -> Curve { + let points = self.points.iter().zip(&self.weights).map(|(q, w)| [q.x, q.y, q.z, *w]).collect(); + Curve::Nurbs { degree: self.degree, points, knots: self.knots.clone() } + } + + /// The point `f` (0…1) of the way along the parameter range. + fn at(&self, f: f64) -> Option { + let ctrl = self.points.iter().zip(&self.weights).map(|(q, w)| [q.x * w, q.y * w, q.z * w, *w]).collect(); + let c = solidcraft_geom::bspline::CurveN::<4> { degree: self.degree, knots: self.knots.clone(), ctrl }; + let (t0, t1) = (*self.knots.first()?, *self.knots.last()?); + let ([x, y, z, w], _) = c.eval(t0 + (t1 - t0) * f.clamp(0.0, 1.0))?; + (w.abs() > 1e-300).then(|| Vec3::new(x / w, y / w, z / w)) + } + } + + /// The point of a section (its curves' nets) nearest `p`: as a wire parameter (the curve's + /// index plus how far along its parameter range), the point, and how far it is. + fn nearest_on(section: &[Net], p: Vec3) -> Option<(f64, Vec3, f64)> { + const SAMPLES: usize = 256; + let mut best: Option<(f64, Vec3, f64)> = None; + for (k, c) in section.iter().enumerate() { + let d = |f: f64| c.at(f).map_or(f64::INFINITY, |q| q.dist(p)); + let Some(i) = (0..=SAMPLES).min_by(|a, b| d(*a as f64 / SAMPLES as f64).total_cmp(&d(*b as f64 / SAMPLES as f64))) else { continue }; + // Golden-section search about the nearest sample. + let (mut lo, mut hi) = ((i as f64 - 1.0).max(0.0) / SAMPLES as f64, (i as f64 + 1.0).min(SAMPLES as f64) / SAMPLES as f64); + let g = 0.5 * (5f64.sqrt() - 1.0); + for _ in 0..80 { + let (a, b) = (hi - g * (hi - lo), lo + g * (hi - lo)); + if d(a) < d(b) { + hi = b; + } else { + lo = a; + } + } + let f = 0.5 * (lo + hi); + let q = c.at(f)?; + if best.as_ref().is_none_or(|b| q.dist(p) < b.2) { + best = Some((k as f64 + f, q, q.dist(p))); + } + } + best + } + + /// The point at wire parameter `g` of a section. + fn wire_point(section: &[Net], g: f64) -> Result { + let k = (g.floor().max(0.0) as usize).min(section.len().saturating_sub(1)); + section.get(k).and_then(|c| c.at(g - k as f64)).ok_or_else(|| KernelError::Failed("loft rails: section".into())) + } + + fn centroid(section: &[Net]) -> Vec3 { + let (sum, n) = section.iter().flat_map(|c| &c.points).fold((Vec3::ZERO, 0usize), |(s, n), q| (s + *q, n + 1)); + sum * (1.0 / n.max(1) as f64) + } + + /// A loft through `sections` (as [`loft`] takes them) guided by one or two `rails` (polylines + /// from the first section to the last, through every section's curve), by the law + /// [`crate::loft_rails`] gives a loft's rails: between each two sections stations are the + /// plain loft there (the sections' control points straight between two, the cubic through + /// them across more) moved so that the points the rails guide land on the rails. Each section + /// curve has to pair with the others' (the same kind, so their control points pair up). + pub(crate) fn loft_rails(sections: &[(Plane, Vec)], first: usize, rails: &[Vec], fit: crate::RailFit) -> Result { + if rails.is_empty() || rails.len() > 2 { + return Err(KernelError::Invalid("a loft takes one or two guide rails".into())); + } + let fail = |m: &str| KernelError::Failed(format!("loft rails: {m}")); + let count = sections.len(); + if count < 2 { + return Err(fail("sections")); + } + let curves: Vec> = sections.iter().map(|(pl, ps)| curves(pl, ps)).collect::>()?; + let nets: Vec> = curves + .iter() + .map(|cs| cs.iter().map(Net::of).collect::>>()) + .collect::>() + .ok_or_else(|| fail("section curves"))?; + let lead = nets.first().ok_or_else(|| fail("sections"))?; + let n = lead.len(); + if n == 0 || nets.iter().any(|s| s.len() != n || s.iter().zip(lead).any(|(a, b)| !a.pairs_with(b))) { + return Err(refuse("loft along guide rails between sections whose curves differ in kind (a line against an arc)")); + } + let mut bounds = solidcraft_geom::Aabb3::EMPTY; + for q in nets.iter().flatten().flat_map(|c| &c.points) { + bounds.add(*q); + } + let size = bounds.diagonal(); + let tol = (size * 1e-4).max(1e-6); + // The sections' parameters along the loft: in proportion to the mean distance between + // their curves' starts. + let mut along = vec![0.0]; + for w in nets.windows(2) { + let step = w[0].iter().zip(&w[1]).filter_map(|(a, b)| Some(a.points.first()?.dist(*b.points.first()?))).sum::() / n as f64; + along.push(along.last().copied().unwrap_or(0.0) + step); + } + let total = along.last().copied().unwrap_or(0.0); + if !(total > 1e-9) || along.windows(2).any(|w| !(w[1] - w[0] > 1e-9 * total)) { + return Err(KernelError::Invalid("two loft sections sit on top of each other".into())); + } + along.iter_mut().for_each(|u| *u /= total); + // The plain loft at v: each control point straight between two sections, the cubic (or + // quadratic) through them across more. + let degree_across = (count - 1).min(3); + let across: Vec>> = if count == 2 { + Vec::new() + } else { + (0..n) + .map(|k| { + (0..lead.get(k).map_or(0, |c| c.points.len())) + .map(|j| { + let pts: Vec<[f64; 3]> = nets.iter().filter_map(|s| s.get(k)?.points.get(j).map(|q| [q.x, q.y, q.z])).collect(); + solidcraft_geom::bspline::CurveN::interpolate(&along, &pts, degree_across).map_err(|e| fail(&e.to_string())) + }) + .collect::>>() + }) + .collect::>()? + }; + let plain = |v: f64| -> Result> { + let (Some(a), Some(b)) = (nets.first(), nets.last()) else { return Err(fail("sections")) }; + if count == 2 { + return Ok(a.iter().zip(b).map(|(ca, cb)| ca.with(ca.points.iter().zip(&cb.points).map(|(p, q)| p.lerp(*q, v)).collect())).collect()); + } + a.iter() + .zip(&across) + .map(|(c, cs)| { + let pts = cs.iter().map(|x| x.eval(v).map(|([px, py, pz], _)| Vec3::new(px, py, pz))).collect::>>(); + pts.map(|p| c.with(p)).ok_or_else(|| fail("the plain loft")) + }) + .collect() + }; + // The plain loft's middle and its direction there. + let middle = |v: f64| -> Result<(Vec3, Vec3)> { + let h = 1e-4; + let (a, b) = ((v - h).max(0.0), (v + h).min(1.0)); + let d = centroid(&plain(b)?) - centroid(&plain(a)?); + let d = d.normalized().ok_or_else(|| KernelError::Invalid("two loft sections sit on top of each other".into()))?; + Ok((centroid(&plain(v)?), d)) + }; + // Each rail from its end nearer the first section's middle, and where it meets each + // section: the guided point (wire parameter), that point, the nudge onto it from the + // crossing, and how far along the rail the crossing is. + let start = centroid(lead); + type Anchor = (f64, Vec3, Vec3, (usize, f64)); + let mut guided: Vec<(Vec, Vec)> = Vec::with_capacity(rails.len()); + for r in rails { + let mut pts = + crate::sweep_path::clean(r).map_err(|_| KernelError::Invalid("a guide rail needs 2…100000 finite points along its length".into()))?; + if let (Some(a), Some(b)) = (pts.first(), pts.last()) + && b.dist(start) < a.dist(start) + { + pts.reverse(); + } + let mut cursor = (0usize, 0.0f64); + let mut anchors = Vec::with_capacity(count); + for (i, ((plane, _), section)) in sections.iter().zip(&nets).enumerate() { + let Some((hit, at)) = crate::sweep_path::rail_crossing(&pts, plane.origin, plane.normal(), cursor, tol) else { + return Err(KernelError::Invalid(format!( + "a guide rail does not reach section {}: a rail has to run from the first section to the last, through every one", + i + 1 + ))); + }; + cursor = at; + let (g, on, d) = nearest_on(section, hit).ok_or_else(|| fail("section"))?; + if d > tol { + return Err(KernelError::Invalid(format!( + "a guide rail crosses section {}'s plane {d:.3} mm from its curves: a rail has to pass through every section", + i + 1 + ))); + } + anchors.push((g, on, on - hit, at)); + } + guided.push((pts, anchors)); + } + if let [a, b] = &guided[..] + && a.1.iter().zip(&b.1).any(|(p, q)| p.1.dist(q.1) <= tol) + { + return Err(KernelError::Invalid("the two guide rails meet a section at the same point".into())); + } + // The sections' normals, turned to agree with the first's. + let mut normals: Vec = Vec::with_capacity(count); + for (p, _) in sections { + let nn = p.normal(); + let prev = normals.last().copied().unwrap_or(nn); + normals.push(if nn.dot(prev) < 0.0 { nn * -1.0 } else { nn }); + } + let spans = count - 1; + let per = (crate::loft_rails::MAX_WIRES.saturating_sub(count) / spans).clamp(1, crate::loft_rails::MAX_PER_SPAN); + let mut all: Vec> = Vec::with_capacity(count + spans * per); + for i in 0..spans { + all.push(nets.get(i).ok_or_else(|| fail("sections"))?.iter().map(Net::curve).collect()); + let (Some(&v0), Some(&v1), Some(&n0), Some(&n1)) = (along.get(i), along.get(i + 1), normals.get(i), normals.get(i + 1)) else { + return Err(fail("sections")); + }; + let mut cursors: Vec<(usize, f64)> = guided.iter().map(|(_, a)| a.get(i).map_or((0, 0.0), |x| x.3)).collect(); + for q in 1..=per { + let f = q as f64 / (per + 1) as f64; + let v = v0 + f * (v1 - v0); + let station = plain(v)?; + let (mid, dir) = middle(v)?; + let normal = n0.lerp(n1, f).normalized().ok_or_else(|| fail("section planes"))?; + let mut pairs = Vec::with_capacity(guided.len()); + for ((pts, anchors), cursor) in guided.iter().zip(cursors.iter_mut()) { + let (Some(a0), Some(a1)) = (anchors.get(i), anchors.get(i + 1)) else { return Err(fail("rail")) }; + let anchor = wire_point(&station, crate::loft_rails::interpolate_g(a0.0, a1.0, f, n, true))?; + let Some((hit, at)) = crate::sweep_path::rail_crossing(pts, anchor, normal, *cursor, tol) else { + return Err(KernelError::Invalid(format!( + "a guide rail turns back between sections {} and {}: it has to run on from one section to the next", + i + 1, + i + 2 + ))); + }; + *cursor = at; + pairs.push((anchor, hit + a0.2 * (1.0 - f) + a1.2 * f)); + } + let map = crate::loft_rails::station_map(&pairs, mid, dir, fit, size)?; + all.push(station.iter().map(|c| c.with(c.points.iter().map(|q| map.apply(*q)).collect()).curve()).collect()); + } + } + all.push(nets.last().ok_or_else(|| fail("sections"))?.iter().map(Net::curve).collect()); + loft_through(sections, first, &all, 3) + } + /// 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 { diff --git a/crates/kernel/src/smlib_sweeps_tests.rs b/crates/kernel/src/smlib_sweeps_tests.rs index 52bb97467d..f0489bb023 100644 --- a/crates/kernel/src/smlib_sweeps_tests.rs +++ b/crates/kernel/src/smlib_sweeps_tests.rs @@ -117,3 +117,50 @@ fn a_sweep_keeps_a_profile_off_its_path_where_it_lies() { 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"); } + +fn numbered(r: &Region2) -> crate::numbered::NumberedRegion { + number_regions(std::slice::from_ref(r)).pop().unwrap().oriented() +} + +#[test] +fn a_sweep_twists_scales_and_follows_rails_through_stations() { + let square = region(Loop2::polygon(&[Vec2::new(-5.0, -5.0), Vec2::new(5.0, -5.0), Vec2::new(5.0, 5.0), Vec2::new(-5.0, 5.0)])); + let up = [crate::PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 40.0) }]; + // Turning about the section's centre keeps its area: V = 100 * 40, the caps and the four + // walls named. + let ctl = crate::SweepControl { twist: PI / 2.0, ..Default::default() }; + let b = smlib_sweeps::sweep_controlled(&Plane::XY, &numbered(&square), &up, &ctl).unwrap(); + close(volume(&b), 4000.0, 1e-3, "twisted square"); + let o = origins(&b); + assert!(o.contains(&cap(false)) && o.contains(&cap(true)) && (0..4).all(|k| o.contains(&profile(k))), "{o:?}"); + // Scaled from 5 to 10 over 30, or between rails that widen as much: a cone frustum. + let disc = region(Loop2::circle(Vec2::ZERO, 5.0)); + let frustum = |h: f64| h / 3.0 * PI * (25.0 + 100.0 + 50.0); + let short = [crate::PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 30.0) }]; + let ctl = crate::SweepControl { scale: 2.0, ..Default::default() }; + close(volume(&smlib_sweeps::sweep_controlled(&Plane::XY, &numbered(&disc), &short, &ctl).unwrap()), frustum(30.0), 1e-3, "end scale"); + let rails = [vec![Vec3::new(5.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 30.0)], vec![Vec3::new(-5.0, 0.0, 0.0), Vec3::new(-10.0, 0.0, 30.0)]]; + for n in 1..=2 { + let ctl = crate::SweepControl { rails: rails[..n].to_vec(), ..Default::default() }; + close(volume(&smlib_sweeps::sweep_controlled(&Plane::XY, &numbered(&disc), &short, &ctl).unwrap()), frustum(30.0), 1e-3, "rails"); + } +} + +#[test] +fn a_twisted_sweep_round_a_closed_path_joins_its_ends() { + // An 8 x 8 square round a circle of radius 30, a quarter turn of twist (onto itself): Pappus. + let xz = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z }; + let square = region(Loop2::polygon(&[Vec2::new(26.0, -4.0), Vec2::new(34.0, -4.0), Vec2::new(34.0, 4.0), Vec2::new(26.0, 4.0)])); + let a = Vec3::new(30.0, 0.0, 0.0); + let round = [ + crate::PathSeg::Arc { a, center: Vec3::ZERO, axis: Vec3::Z, angle: PI }, + crate::PathSeg::Arc { a: -a, center: Vec3::ZERO, axis: Vec3::Z, angle: PI }, + ]; + let ctl = crate::SweepControl { twist: PI / 2.0, ..Default::default() }; + let b = smlib_sweeps::sweep_controlled(&xz, &numbered(&square), &round, &ctl).unwrap(); + close(volume(&b), 64.0 * TAU * 30.0, 1e-3, "twisted ring"); + assert!(origins(&b).iter().all(|o| o.contains("Profile")), "{:?}", origins(&b)); + // A twist that does not bring the square back onto itself cannot close. + let ctl = crate::SweepControl { twist: PI / 3.0, ..Default::default() }; + assert!(smlib_sweeps::sweep_controlled(&xz, &numbered(&square), &round, &ctl).is_err()); +} diff --git a/crates/kernel/src/sweep_path.rs b/crates/kernel/src/sweep_path.rs index b0720affc9..53490c4af1 100644 --- a/crates/kernel/src/sweep_path.rs +++ b/crates/kernel/src/sweep_path.rs @@ -104,7 +104,7 @@ impl SweepControl { } /// Whether the profile changes along the path (it twists, scales or follows rails). - fn varies(&self) -> bool { + pub(crate) fn varies(&self) -> bool { self.twist != 0.0 || self.scale != 1.0 || !self.rails.is_empty() } @@ -141,13 +141,13 @@ impl SweepControl { /// A linear map of world vectors (columns) plus the station it is centred at. #[derive(Clone, Copy)] -struct Station { - at: Vec3, - m: [Vec3; 3], +pub(crate) struct Station { + pub at: Vec3, + pub m: [Vec3; 3], } impl Station { - fn apply(&self, v: Vec3) -> Vec3 { + pub(crate) fn apply(&self, v: Vec3) -> Vec3 { self.m[0] * v.x + self.m[1] * v.y + self.m[2] * v.z } } @@ -177,7 +177,7 @@ pub(crate) fn closed_path(pts: &[Vec3]) -> bool { /// The cyclic correspondence of a loop's last section to its first. Matching whole curves /// (rather than vertices alone) prevents sewing an incompatible twist or scale shut. -fn closing_offset(plane: &Plane, profile: &NumberedLoop, st: &[Station]) -> Result { +pub(crate) fn closing_offset(plane: &Plane, profile: &NumberedLoop, st: &[Station]) -> Result { let fail = || KernelError::Invalid("a sweep along a closed path must return to its starting profile; adjust the twist, end scale or rails".into()); let (Some(first), Some(last)) = (st.first(), st.last()) else { return Err(fail()) }; @@ -301,7 +301,13 @@ fn frames_toward(tangents: &[Vec3], d: Vec3) -> Result> { /// at its own (twisted and scaled about the profile's normal through the first station, then /// turned by the orientation's frames), optionally stretched across a corner (mitre). Twist and /// scale grow with the length run along `pts`. -fn controlled_stations(plane: &Plane, pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>], ctl: &SweepControl) -> Result> { +pub(crate) fn controlled_stations( + plane: &Plane, + pts: &[Vec3], + tangents: &[Vec3], + mitre: &[Option<(Vec3, f64)>], + ctl: &SweepControl, +) -> Result> { if !ctl.rails.is_empty() { return railed_stations(pts, tangents, mitre, ctl); } @@ -524,7 +530,7 @@ 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)")); + return smlib_sweep_points(plane, region, path, ctl); } if region.area().abs() < 1e-9 { return Err(KernelError::Invalid("profile has no area".into())); @@ -560,6 +566,24 @@ pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &Swe ruled(plane, &profile, &pts, ctl) } +/// [`sweep_path_with`] through SMLib: a path of points is the polyline through them, its +/// straight runs the lines between them. A line, or a polyline with corners, is swept as those +/// lines ([`crate::sweep_with`], which takes a path with corners only where SMLib has a sweep for +/// it); a smooth polyline of more points stands for a curve (a drawn 3D spline) that SMLib has no +/// path for yet. +fn smlib_sweep_points(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &SweepControl) -> Result { + let pts = clean(path)?; + let smooth = pts.len() > 2 && !pts.windows(3).any(|w| turn(w[1] - w[0], w[2] - w[1]) > CORNER); + if smooth { + return Err(crate::smlib_sweeps::refuse("sweep along a smooth path of points (a 3D sketch spline)")); + } + let segs: Vec = pts.windows(2).map(|w| crate::PathSeg::Line { a: w[0], b: w[1] }).collect(); + if segs.len() > 1000 { + return Err(KernelError::Invalid("the path needs 1…1000 segments".into())); + } + crate::sweep_with(plane, region, &segs, ctl) +} + fn central_tangents(p: &[Vec3]) -> Result> { let n = p.len(); let closed = closed_path(p);