Sweep guide rails and two-rail sweep (#771) (#1806)

Thanks @jyviko.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-17 04:07:46 -04:00
1 parent 7ba2d5db47
commit 98ce6a4ee8
22 files changed
+804 -55

No files matched your search

+156 -9
View File
@@ -1,8 +1,9 @@
//! Sweeps along 3D point paths (3D sketch curves: projected, intersection and included curves).
//! The profile follows the path with a rotation-minimising frame. Smooth paths get one B-spline
//! wall per profile segment; paths with sharp corners (or profiles with holes) get ruled walls
//! between stations, mitred at the corners. Sweep options (orientation, twist, end scale; see
//! [`SweepControl`]) reshape the stations; their default leaves them exactly as described.
//! between stations, mitred at the corners. Sweep options (orientation, twist, end scale, guide
//! rails; see [`SweepControl`]) reshape the stations; their default leaves them exactly as
//! described.
use mt::builder;
use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3};
@@ -48,21 +49,44 @@ pub enum SweepOrientation {
Direction(Vec3),
}
/// How guide rails reshape the profile at each station.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RailFit {
/// The profile grows or shrinks evenly with its distance across to the rail (between the
/// two rails).
#[default]
Scale,
/// The profile stretches only across to the rail (between the rails), keeping its size the
/// other way.
Stretch,
}
/// Sweep options beyond following the path. `twist` turns the profile about the path by that
/// many radians over the path's length (right hand about the direction of travel, in proportion
/// to the length run); `scale` is the profile's size at the end, reached linearly along the
/// path's length, about the path's start point. The default (perpendicular, no twist, scale 1)
/// is a plain sweep.
#[derive(Clone, Copy, Debug, PartialEq)]
/// path's length, about the path's start point. The default (perpendicular, no twist, scale 1,
/// no rails) is a plain sweep.
///
/// `rails` are guide rails, as polylines from the end nearest the profile. Each station of the
/// path is square to it; a rail meets the station's plane at one point. With one rail, the
/// profile turns about the path so that its point where the rail starts stays on the rail, and
/// is scaled (or stretched, see [`RailFit`]) by how far the rail is from the path compared with
/// the start. With two rails, the profile's points where the rails start stay on both: it moves
/// with the first rail, turns with the line between them and scales (stretches) with their
/// distance apart. Rails decide the profile's turn and size, so they take the default
/// orientation, no twist and scale 1, and a path without sharp corners.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepControl {
pub orientation: SweepOrientation,
pub twist: f64,
pub scale: f64,
pub rails: Vec<Vec<Vec3>>,
pub rail_fit: RailFit,
}
impl Default for SweepControl {
fn default() -> Self {
SweepControl { orientation: SweepOrientation::Perpendicular, twist: 0.0, scale: 1.0 }
SweepControl { orientation: SweepOrientation::Perpendicular, twist: 0.0, scale: 1.0, rails: Vec::new(), rail_fit: RailFit::Scale }
}
}
@@ -79,13 +103,15 @@ impl SweepControl {
*self == SweepControl::default()
}
/// Whether the profile changes along the path (it twists or scales).
/// Whether the profile changes along the path (it twists, scales or follows rails).
fn varies(&self) -> bool {
self.twist != 0.0 || self.scale != 1.0
self.twist != 0.0 || self.scale != 1.0 || !self.rails.is_empty()
}
/// Refuses options out of range: a twist beyond [`Self::MAX_TWIST`], a scale outside
/// [`Self::MIN_SCALE`]…[`Self::MAX_SCALE`], a direction that is not finite or has no length.
/// [`Self::MIN_SCALE`]…[`Self::MAX_SCALE`], a direction that is not finite or has no length,
/// more than two rails, a rail that is not 2…100000 finite points, and rails with any other
/// orientation, a twist or an end scale.
pub fn check(&self) -> Result<()> {
if !(self.twist.is_finite() && self.twist.abs() <= Self::MAX_TWIST) {
return Err(KernelError::Invalid("the sweep twist must be within four turns (1440 deg)".into()));
@@ -98,6 +124,17 @@ impl SweepControl {
{
return Err(KernelError::Invalid("the sweep orientation direction must be a finite, non-zero vector".into()));
}
if self.rails.len() > 2 {
return Err(KernelError::Invalid("a sweep takes one or two guide rails".into()));
}
for rail in &self.rails {
clean(rail).map_err(|_| KernelError::Invalid("a guide rail needs 2…100000 finite points along its length".into()))?;
}
if !self.rails.is_empty() && (self.orientation != SweepOrientation::Perpendicular || self.twist != 0.0 || self.scale != 1.0) {
return Err(KernelError::Invalid(
"guide rails set the profile's turn and size along the path: leave the orientation, twist and end scale at their defaults".into(),
));
}
Ok(())
}
}
@@ -228,6 +265,9 @@ fn frames_toward(tangents: &[Vec3], d: Vec3) -> Result<Vec<[Vec3; 3]>> {
/// 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<Vec<Station>> {
if !ctl.rails.is_empty() {
return railed_stations(pts, tangents, mitre, ctl);
}
let bad = || KernelError::Invalid("sweep path".into());
let t0 = *tangents.first().ok_or_else(bad)?;
let f: Vec<[Vec3; 3]> = match ctl.orientation {
@@ -284,6 +324,113 @@ fn controlled_stations(plane: &Plane, pts: &[Vec3], tangents: &[Vec3], mitre: &[
Ok(out)
}
/// Where a rail polyline crosses the plane through `p` square to `t`: the first crossing at or
/// after `from` (segment index and fraction along it), with the position it was found at, so a
/// walk along the path finds each rail point after the last. Within `tol` of the plane a rail's
/// first and last points count as on it (a rail drawn to end where the path ends).
fn rail_crossing(rail: &[Vec3], p: Vec3, t: Vec3, from: (usize, f64), tol: f64) -> Option<(Vec3, (usize, f64))> {
let f = |q: Vec3| (q - p).dot(t);
let (k0, u0) = from;
if k0 == 0 && u0 == 0.0 && rail.first().is_some_and(|q| f(*q).abs() <= tol) {
return rail.first().map(|q| (*q, (0, 0.0)));
}
for k in k0..rail.len().saturating_sub(1) {
let (a, b) = (*rail.get(k)?, *rail.get(k + 1)?);
let a = if k == k0 { a.lerp(b, u0) } else { a };
let (fa, fb) = (f(a), f(b));
if fa == 0.0 && (k != k0 || u0 == 0.0) {
return Some((a, (k, if k == k0 { u0 } else { 0.0 })));
}
if (fa < 0.0) != (fb < 0.0) || fb == 0.0 {
let s = fa / (fa - fb);
let q = a.lerp(b, s);
// The fraction along the whole segment, for the next walk.
let base = if k == k0 { u0 } else { 0.0 };
return Some((q, (k, base + (1.0 - base) * s)));
}
}
let last = rail.last()?;
(f(*last).abs() <= tol).then(|| (*last, (rail.len().saturating_sub(2), 1.0)))
}
/// Stations shaped by guide rails (see [`SweepControl`]): each is square to the path, turned
/// about it to follow the rail (the line between the rails) and scaled or stretched by the
/// rail's distance across (the rails' distance apart) against the start's. A rail has to meet
/// the plane of every station, in order along its length.
fn railed_stations(pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>], ctl: &SweepControl) -> Result<Vec<Station>> {
// A polyline path's points bend it a little (its arcs' chords); a corner is a bend past
// [`CORNER`], where the stations would have to be mitred.
let corner = 1.0 / (CORNER / 2.0).cos();
if mitre.iter().flatten().any(|(_, s)| *s > corner + 1e-12) {
return Err(KernelError::Invalid("not supported yet: guide rails along a path with sharp corners".into()));
}
let rails: Vec<Vec<Vec3>> = ctl.rails.iter().map(|r| clean(r)).collect::<Result<_>>()?;
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
let tol = 1e-3f64.max(1e-6 * len);
let mut cursors = vec![(0usize, 0.0f64); rails.len()];
// At each station: the anchor the profile moves with, and the vector across that turns and
// sizes it (to the rail, or from the first rail to the second).
let mut across = Vec::with_capacity(pts.len());
for (i, (p, t)) in pts.iter().zip(tangents).enumerate() {
let mut hits = Vec::with_capacity(rails.len());
for (rail, cursor) in rails.iter().zip(cursors.iter_mut()) {
let Some((q, at)) = rail_crossing(rail, *p, *t, *cursor, tol) else {
let done = 100.0 * i as f64 / pts.len().saturating_sub(1).max(1) as f64;
return Err(KernelError::Invalid(format!(
"a guide rail does not reach the path's plane {done:.0}% of the way along it: the rail has to run the whole length of the path"
)));
};
*cursor = at;
hits.push(q);
}
let (anchor, v) = match hits[..] {
[r] => (*p, r - *p),
[a, b] => (a, b - a),
_ => return Err(KernelError::Invalid("a sweep takes one or two guide rails".into())),
};
across.push((anchor, v - *t * v.dot(*t)));
}
let (Some(&(a0, d0)), Some(&t0)) = (across.first(), tangents.first()) else { return Err(KernelError::Invalid("sweep path".into())) };
let s0 = *pts.first().ok_or_else(|| KernelError::Invalid("sweep path".into()))?;
let near = |d: Vec3| d.len() <= 1e-6 * (1.0 + len);
if near(d0) {
return Err(KernelError::Invalid(if rails.len() == 1 {
"the guide rail starts on the path: it has to start away from the path, at the point of the profile it guides".into()
} else {
"the two guide rails start at the same point".into()
}));
}
let (u0, w0) = {
let u = d0.normalized().ok_or_else(|| KernelError::Invalid("guide rail".into()))?;
(u, t0.cross(u))
};
let mut out = Vec::with_capacity(pts.len());
for ((anchor, d), t) in across.iter().zip(tangents) {
if near(*d) {
return Err(KernelError::Invalid(if rails.len() == 1 {
format!("the guide rail meets the path near ({:.3}, {:.3}, {:.3}): the profile would shrink to nothing", anchor.x, anchor.y, anchor.z)
} else {
format!("the guide rails meet near ({:.3}, {:.3}, {:.3}): the profile would shrink to nothing", anchor.x, anchor.y, anchor.z)
}));
}
let k = d.len() / d0.len();
let k_other = match ctl.rail_fit {
RailFit::Scale => k,
RailFit::Stretch => 1.0,
};
let u = d.normalized().ok_or_else(|| KernelError::Invalid("guide rail".into()))?;
let w = t.cross(u);
// Along the path stays along it; across to the rail turns onto the rail and sizes with
// it; the third way follows, scaled evenly or kept.
let map = |v: Vec3| *t * v.dot(t0) + u * (k * v.dot(u0)) + w * (k_other * v.dot(w0));
let m = [map(Vec3::X), map(Vec3::Y), map(Vec3::Z)];
let st = Station { at: Vec3::default(), m };
// The anchor's start position lands on the anchor.
out.push(Station { at: *anchor - st.apply(a0 - s0), m });
}
Ok(out)
}
/// The volume a profile of `area` on `plane` sweeps through the stations: each station's
/// section area times how squarely the path crosses it, summed along the path (trapezoids).
fn swept_volume(plane: &Plane, area: f64, st: &[Station], tangents: &[Vec3]) -> f64 {