Sweep: orientation, twist and end scale (#1269)
Part of #771 and #772. solid.sweep and surface.sweep take orientation (perpendicular, parallel or a direction), twist (up to 4 turns) and end scale (0.01 to 100), applied about the profile's normal through the path's start; default options run the old sweep, so existing sweeps are unchanged. New fields are optional, left out when unset and noted under format 21; twist and scale are model parameters. Both sweep dialogs get Orientation, Twist and End Scale rows; labels in every catalog. Checked on main + this branch: cargo check --workspace, kernel/engine/UI sweep tests, the doc suite and the robustness goldens.
This commit is contained in:
1 parent
0872eb959a
commit
5fa8e7d60a
30 files changed
+1120
-71
No files matched your search
@@ -1786,6 +1786,38 @@ 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))
|
||||
}
|
||||
|
||||
/// The path as a polyline: its first point, then each segment's points (arcs within 0.01 mm of
|
||||
/// their chords).
|
||||
pub(crate) fn path_polyline(path: &[PathSeg]) -> Result<Vec<Vec3>> {
|
||||
let first = match path.first() {
|
||||
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||||
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||||
};
|
||||
let mut pts = vec![first];
|
||||
for seg in path {
|
||||
pts.extend(seg.points_after_start(1e-2));
|
||||
}
|
||||
Ok(pts)
|
||||
}
|
||||
|
||||
/// [`sweep`] with sweep options. The default options are exactly [`sweep`]; any other
|
||||
/// 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 ctl.is_default() {
|
||||
return sweep(plane, region, path);
|
||||
}
|
||||
ctl.check()?;
|
||||
region_ok(region)?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
}
|
||||
if path.iter().any(|s| s.tangents().is_none()) {
|
||||
return Err(KernelError::Invalid("a path segment has no direction".into()));
|
||||
}
|
||||
crate::sweep_path::sweep_path_with(plane, region, &path_polyline(path)?, ctl)
|
||||
}
|
||||
|
||||
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
|
||||
/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners
|
||||
/// is swept as a polyline, mitred at the corners.
|
||||
@@ -1800,15 +1832,7 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
|
||||
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos());
|
||||
if corner {
|
||||
let first = match path.first() {
|
||||
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||||
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||||
};
|
||||
let mut pts = vec![first];
|
||||
for seg in path {
|
||||
pts.extend(seg.points_after_start(1e-2));
|
||||
}
|
||||
return crate::sweep_path::sweep_path(plane, region, &pts);
|
||||
return crate::sweep_path::sweep_path(plane, region, &path_polyline(path)?);
|
||||
}
|
||||
// The outer loop and each hole, swept along the path; the ends are capped with holes.
|
||||
let profile = region_wires(plane, &oriented(region)?)?;
|
||||
|
||||
@@ -118,7 +118,8 @@ pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fill
|
||||
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
|
||||
pub use build::{
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep, torus,
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep,
|
||||
sweep_with, torus,
|
||||
};
|
||||
pub use delete_face::delete_faces;
|
||||
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
|
||||
@@ -148,9 +149,9 @@ pub use surface_ruled::{Ruled, RuledCorners, RuledMode, ruled_surface};
|
||||
pub use surface_untrim::{UntrimHoles, untrim};
|
||||
pub use surfaces::{
|
||||
copy_faces, extend, extrude_sheet, loft_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet,
|
||||
sweep_sheet_points, thicken, trim, unstitch,
|
||||
sweep_sheet_points, sweep_sheet_points_with, sweep_sheet_with, thicken, trim, unstitch,
|
||||
};
|
||||
pub use sweep_path::sweep_path;
|
||||
pub use sweep_path::{SweepControl, SweepOrientation, sweep_path, sweep_path_with};
|
||||
pub use topo::{CylinderFace, cylinder_face_at};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
pub use tspline::tspline_sheet;
|
||||
|
||||
@@ -211,16 +211,8 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg])
|
||||
}
|
||||
let ends: Vec<(Vec3, Vec3)> =
|
||||
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let first = match path.first() {
|
||||
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||||
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||||
};
|
||||
if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) {
|
||||
let mut pts = vec![first];
|
||||
for seg in path {
|
||||
pts.extend(seg.points_after_start(1e-2));
|
||||
}
|
||||
return sweep_sheet_points(plane, chains, &pts);
|
||||
return sweep_sheet_points(plane, chains, &crate::build::path_polyline(path)?);
|
||||
}
|
||||
let mut sides = Vec::new();
|
||||
let mut offset = 0;
|
||||
@@ -254,16 +246,39 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg])
|
||||
/// B-spline wall per segment where the path has no corners, else ruled walls between the path's
|
||||
/// points, mitred at the corners (see [`sweep_sheet`]).
|
||||
pub fn sweep_sheet_points(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Body> {
|
||||
sweep_sheet_points_with(plane, chains, path, &crate::SweepControl::default())
|
||||
}
|
||||
|
||||
/// [`sweep_sheet`] with sweep options. The default options are exactly [`sweep_sheet`]; any
|
||||
/// other orientation, a twist or an end scale follow the path as a polyline through stations
|
||||
/// (see [`sweep_sheet_points_with`]).
|
||||
pub fn sweep_sheet_with(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
|
||||
if ctl.is_default() {
|
||||
return sweep_sheet(plane, chains, path);
|
||||
}
|
||||
ctl.check()?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
}
|
||||
if path.iter().any(|s| s.tangents().is_none()) {
|
||||
return Err(KernelError::Invalid("a path segment has no direction".into()));
|
||||
}
|
||||
sweep_sheet_points_with(plane, chains, &crate::build::path_polyline(path)?, ctl)
|
||||
}
|
||||
|
||||
/// [`sweep_sheet_points`] with sweep options: orientation, twist and end scale.
|
||||
pub fn sweep_sheet_points_with(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &crate::SweepControl) -> Result<Body> {
|
||||
ctl.check()?;
|
||||
if !crate::build::frame_ok(plane) {
|
||||
return Err(KernelError::Invalid("sweep frame is not finite and orthonormal".into()));
|
||||
}
|
||||
if chains.is_empty() || chains.len() > MAX_CHAINS {
|
||||
return Err(KernelError::Invalid("pick the curves or profiles to sweep".into()));
|
||||
}
|
||||
let ruled = sheet_body(crate::sweep_path::ruled_sheet(plane, chains, path)?)?;
|
||||
let ruled = sheet_body(crate::sweep_path::ruled_sheet(plane, chains, path, ctl)?)?;
|
||||
// Smooth walls where the path has no corners, kept when their area agrees with the ruled
|
||||
// walls' (a fit that went wrong falls back to those).
|
||||
if let Ok(Some(sides)) = crate::sweep_path::smooth_sheet(plane, chains, path)
|
||||
if let Ok(Some(sides)) = crate::sweep_path::smooth_sheet(plane, chains, path, ctl)
|
||||
&& let Ok(smooth) = sheet_body(sides)
|
||||
&& let (Ok(a), Ok(b)) = (crate::measure(&smooth), crate::measure(&ruled))
|
||||
&& (a.area - b.area).abs() <= 0.02 * b.area.abs()
|
||||
|
||||
+387
-16
@@ -1,7 +1,8 @@
|
||||
//! 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.
|
||||
//! between stations, mitred at the corners. Sweep options (orientation, twist, end scale; see
|
||||
//! [`SweepControl`]) reshape the stations; their default leaves them exactly as described.
|
||||
|
||||
use mt::builder;
|
||||
use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3};
|
||||
@@ -23,6 +24,83 @@ const MAX_RULED_STATIONS: usize = 2000;
|
||||
const CORNER: f64 = 0.35;
|
||||
/// Samples per profile segment of the smooth walls.
|
||||
const SEG_SAMPLES: usize = 12;
|
||||
/// Stations of a smooth sweep that twists or scales, at the least.
|
||||
const MIN_CONTROLLED_STATIONS: usize = 16;
|
||||
/// Stations of a smooth sweep that twists or scales, at the most.
|
||||
const MAX_CONTROLLED_STATIONS: usize = 160;
|
||||
/// Stations of a ruled sweep that twists or scales, at the least.
|
||||
const MIN_CONTROLLED_RULED: usize = 64;
|
||||
/// The most twist one station of a smooth sweep may carry (radians).
|
||||
const SMOOTH_TWIST_STEP: f64 = std::f64::consts::PI / 18.0;
|
||||
/// The most twist one station of a ruled sweep may carry (radians).
|
||||
const RULED_TWIST_STEP: f64 = std::f64::consts::PI / 36.0;
|
||||
|
||||
/// How the profile turns as it follows a sweep path.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub enum SweepOrientation {
|
||||
/// Square to the path, turned along it by a rotation-minimising frame.
|
||||
#[default]
|
||||
Perpendicular,
|
||||
/// As drawn: the profile only moves along the path, never turning.
|
||||
Parallel,
|
||||
/// Square to the path, its turn about the path fixed by a direction: the direction's
|
||||
/// projection onto the profile's plane stays where it was at the start.
|
||||
Direction(Vec3),
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct SweepControl {
|
||||
pub orientation: SweepOrientation,
|
||||
pub twist: f64,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
impl Default for SweepControl {
|
||||
fn default() -> Self {
|
||||
SweepControl { orientation: SweepOrientation::Perpendicular, twist: 0.0, scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl SweepControl {
|
||||
/// The most twist accepted: four turns (radians).
|
||||
pub const MAX_TWIST: f64 = 4.0 * std::f64::consts::TAU;
|
||||
/// The smallest end scale accepted.
|
||||
pub const MIN_SCALE: f64 = 0.01;
|
||||
/// The largest end scale accepted.
|
||||
pub const MAX_SCALE: f64 = 100.0;
|
||||
|
||||
/// A plain sweep: the options change nothing.
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == SweepControl::default()
|
||||
}
|
||||
|
||||
/// Whether the profile changes along the path (it twists or scales).
|
||||
fn varies(&self) -> bool {
|
||||
self.twist != 0.0 || self.scale != 1.0
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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()));
|
||||
}
|
||||
if !(self.scale.is_finite() && (Self::MIN_SCALE..=Self::MAX_SCALE).contains(&self.scale)) {
|
||||
return Err(KernelError::Invalid("the sweep end scale must be between 0.01 and 100".into()));
|
||||
}
|
||||
if let SweepOrientation::Direction(d) = self.orientation
|
||||
&& !(d.is_finite() && d.len() > 1e-9 && d.len() < 1e9)
|
||||
{
|
||||
return Err(KernelError::Invalid("the sweep orientation direction must be a finite, non-zero vector".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A linear map of world vectors (columns) plus the station it is centred at.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -127,6 +205,124 @@ fn stations(pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>]) -> R
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Frames square to the path whose turn about it keeps `d`'s projection: none where the path
|
||||
/// runs along `d`.
|
||||
fn frames_toward(tangents: &[Vec3], d: Vec3) -> Result<Vec<[Vec3; 3]>> {
|
||||
let d = d.normalized().ok_or_else(|| KernelError::Invalid("the sweep orientation direction has no length".into()))?;
|
||||
tangents
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let across = d - *t * t.dot(d);
|
||||
// Within about 0.06 degrees of the direction the profile's turn is undefined.
|
||||
if across.len() < 1e-3 {
|
||||
return Err(KernelError::Invalid("the sweep path runs along the orientation direction".into()));
|
||||
}
|
||||
let r = across.normalized().ok_or_else(|| KernelError::Invalid("the sweep path runs along the orientation direction".into()))?;
|
||||
Ok([*t, r, t.cross(r)])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stations shaped by sweep options: each maps a world vector at the first station to its image
|
||||
/// 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<Vec<Station>> {
|
||||
let bad = || KernelError::Invalid("sweep path".into());
|
||||
let t0 = *tangents.first().ok_or_else(bad)?;
|
||||
let f: Vec<[Vec3; 3]> = match ctl.orientation {
|
||||
SweepOrientation::Perpendicular => frames(pts, tangents).ok_or_else(|| KernelError::Invalid("the sweep path doubles back".into()))?,
|
||||
SweepOrientation::Parallel => vec![[Vec3::X, Vec3::Y, Vec3::Z]; pts.len()],
|
||||
SweepOrientation::Direction(d) => frames_toward(tangents, d)?,
|
||||
};
|
||||
let f0 = *f.first().ok_or_else(bad)?;
|
||||
// Twist and scale act about the profile's normal, pointing the way the path leaves it.
|
||||
let n = plane.normal().normalized().ok_or_else(|| KernelError::Invalid("sweep frame".into()))?;
|
||||
let axis = if n.dot(t0) < 0.0 { n * -1.0 } else { n };
|
||||
// Kept as drawn, the profile flattens to nothing where the path runs along its plane: the
|
||||
// path has to cross it at 5 degrees or more all the way.
|
||||
if ctl.orientation == SweepOrientation::Parallel && tangents.iter().any(|t| t.dot(axis) < 5f64.to_radians().sin()) {
|
||||
return Err(KernelError::Invalid(
|
||||
"kept parallel, the profile would lie along the sweep path: the path must keep crossing the profile's plane (by 5 deg or more)".into(),
|
||||
));
|
||||
}
|
||||
let mut run = Vec::with_capacity(pts.len());
|
||||
let mut total = 0.0;
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if let Some(prev) = i.checked_sub(1).and_then(|j| pts.get(j)) {
|
||||
total += prev.dist(*p);
|
||||
}
|
||||
run.push(total);
|
||||
}
|
||||
if !(total > 1e-12) {
|
||||
return Err(KernelError::Invalid("the sweep path has no length".into()));
|
||||
}
|
||||
let mut out = Vec::with_capacity(f.len());
|
||||
for (i, fi) in f.iter().enumerate() {
|
||||
let u = run.get(i).copied().unwrap_or(total) / total;
|
||||
let (sin, cos) = (ctl.twist * u).sin_cos();
|
||||
let k = 1.0 + (ctl.scale - 1.0) * u;
|
||||
let shape = |v: Vec3| {
|
||||
let along = axis * v.dot(axis);
|
||||
let r = (v - along) * k;
|
||||
along + r * cos + axis.cross(r) * sin
|
||||
};
|
||||
let col = |e: Vec3| {
|
||||
let v = shape(e);
|
||||
fi[0] * f0[0].dot(v) + fi[1] * f0[1].dot(v) + fi[2] * f0[2].dot(v)
|
||||
};
|
||||
let mut m = [col(Vec3::X), col(Vec3::Y), col(Vec3::Z)];
|
||||
if ctl.orientation != SweepOrientation::Parallel
|
||||
&& let Some(Some((b, s))) = mitre.get(i)
|
||||
{
|
||||
for c in &mut m {
|
||||
*c += *b * ((s - 1.0) * c.dot(*b));
|
||||
}
|
||||
}
|
||||
out.push(Station { at: *pts.get(i).ok_or_else(bad)?, 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 {
|
||||
let rate = |i: usize| match (st.get(i), tangents.get(i)) {
|
||||
(Some(s), Some(t)) => area * s.apply(plane.x).cross(s.apply(plane.y)).dot(*t).abs(),
|
||||
_ => 0.0,
|
||||
};
|
||||
st.windows(2).enumerate().map(|(i, w)| 0.5 * (rate(i) + rate(i + 1)) * w[0].at.dist(w[1].at)).sum()
|
||||
}
|
||||
|
||||
/// The stations of a smooth sweep: as many as the path has points (4…[`MAX_SMOOTH_STATIONS`]),
|
||||
/// more when the profile twists or scales.
|
||||
fn smooth_count(points: usize, ctl: &SweepControl) -> usize {
|
||||
let n = points.saturating_sub(1).clamp(4, MAX_SMOOTH_STATIONS);
|
||||
if !ctl.varies() {
|
||||
return n;
|
||||
}
|
||||
let twist = (ctl.twist.abs() / SMOOTH_TWIST_STEP).ceil() as usize;
|
||||
n.max(MIN_CONTROLLED_STATIONS).max(twist).min(MAX_CONTROLLED_STATIONS)
|
||||
}
|
||||
|
||||
/// The path with points added along its straight runs (its corners kept) when the profile
|
||||
/// twists or scales, so ruled walls follow the change.
|
||||
fn densify(pts: &[Vec3], ctl: &SweepControl) -> Vec<Vec3> {
|
||||
if !ctl.varies() {
|
||||
return pts.to_vec();
|
||||
}
|
||||
let total: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let want = MIN_CONTROLLED_RULED.max((ctl.twist.abs() / RULED_TWIST_STEP).ceil() as usize).min(MAX_RULED_STATIONS / 2);
|
||||
let mut out = Vec::with_capacity(want + pts.len());
|
||||
out.extend(pts.first().copied());
|
||||
for w in pts.windows(2) {
|
||||
let pieces = if total > 0.0 { ((w[0].dist(w[1]) / total) * want as f64).ceil() as usize } else { 1 };
|
||||
let pieces = pieces.clamp(1, want.max(1));
|
||||
out.extend((1..=pieces).map(|j| w[0].lerp(w[1], j as f64 / pieces as f64)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The profile plane carried to a station.
|
||||
fn station_plane(plane: &Plane, s0: Vec3, st: &Station) -> Plane {
|
||||
Plane { origin: st.at + st.apply(plane.origin - s0), x: st.apply(plane.x), y: st.apply(plane.y) }
|
||||
@@ -135,6 +331,12 @@ fn station_plane(plane: &Plane, s0: Vec3, st: &Station) -> Plane {
|
||||
/// Sweep a planar region along a 3D polyline path. The profile keeps its relation to the path
|
||||
/// (it need not touch it); the path should start near the profile.
|
||||
pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body> {
|
||||
sweep_path_with(plane, region, path, &SweepControl::default())
|
||||
}
|
||||
|
||||
/// [`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 region.area().abs() < 1e-9 {
|
||||
return Err(KernelError::Invalid("profile has no area".into()));
|
||||
}
|
||||
@@ -147,16 +349,21 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body
|
||||
if !corner && region.holes.is_empty() {
|
||||
// Smooth: evenly spaced stations, tangents from neighbours.
|
||||
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let n = (pts.len() - 1).clamp(4, MAX_SMOOTH_STATIONS);
|
||||
let n = smooth_count(pts.len(), ctl);
|
||||
let sp = resample(&pts, n);
|
||||
let tangents = central_tangents(&sp)?;
|
||||
let st = stations(&sp, &tangents, &[])?;
|
||||
let expected = region.area().abs() * len * start_cos(plane, &tangents);
|
||||
let (st, expected) = if ctl.is_default() {
|
||||
(stations(&sp, &tangents, &[])?, region.area().abs() * len * start_cos(plane, &tangents))
|
||||
} else {
|
||||
let st = controlled_stations(plane, &sp, &tangents, &[], ctl)?;
|
||||
let v = swept_volume(plane, region.area().abs(), &st, &tangents);
|
||||
(st, v)
|
||||
};
|
||||
if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) {
|
||||
return Ok(b);
|
||||
}
|
||||
}
|
||||
ruled(plane, &profile, &pts)
|
||||
ruled(plane, &profile, &pts, ctl)
|
||||
}
|
||||
|
||||
fn central_tangents(p: &[Vec3]) -> Result<Vec<Vec3>> {
|
||||
@@ -177,8 +384,9 @@ fn start_cos(plane: &Plane, tangents: &[Vec3]) -> f64 {
|
||||
|
||||
/// Stations at the path's points (at most [`MAX_RULED_STATIONS`]), mitred at corners, with the
|
||||
/// points they stand at.
|
||||
fn ruled_stations(pts: &[Vec3]) -> Result<(Vec<Vec3>, Vec<Station>)> {
|
||||
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(pts, MAX_RULED_STATIONS) } else { pts.to_vec() };
|
||||
fn ruled_stations(plane: &Plane, pts: &[Vec3], ctl: &SweepControl) -> Result<(Vec<Vec3>, Vec<Station>)> {
|
||||
let pts = densify(pts, ctl);
|
||||
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(&pts, MAX_RULED_STATIONS) } else { pts };
|
||||
let n = pts.len();
|
||||
let seg_dir = |i: usize| -> Option<Vec3> { (*pts.get(i + 1)? - *pts.get(i)?).normalized() };
|
||||
let mut tangents = Vec::with_capacity(n);
|
||||
@@ -201,15 +409,16 @@ fn ruled_stations(pts: &[Vec3]) -> Result<(Vec<Vec3>, Vec<Station>)> {
|
||||
tangents.push(t);
|
||||
mitre.push(m);
|
||||
}
|
||||
let st = stations(&pts, &tangents, &mitre)?;
|
||||
let st = if ctl.is_default() { stations(&pts, &tangents, &mitre)? } else { controlled_stations(plane, &pts, &tangents, &mitre, ctl)? };
|
||||
Ok((pts, st))
|
||||
}
|
||||
|
||||
/// Walls of chains of sketch segments on `plane` (open, or closed) carried along a 3D polyline
|
||||
/// path that starts near them: ruled between stations at the path's points, mitred at corners,
|
||||
/// with no caps. Neighbouring walls share their edges.
|
||||
pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Vec<Side>> {
|
||||
let (pts, st) = ruled_stations(&clean(path)?)?;
|
||||
pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &SweepControl) -> Result<Vec<Side>> {
|
||||
ctl.check()?;
|
||||
let (pts, st) = ruled_stations(plane, &clean(path)?, ctl)?;
|
||||
let edges: usize = chains.iter().map(Vec::len).sum();
|
||||
if st.len().saturating_mul(edges) > 400_000 {
|
||||
return Err(KernelError::Invalid("the sweep is too detailed (curves × path points)".into()));
|
||||
@@ -249,7 +458,8 @@ pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) ->
|
||||
/// turn sharper than [`CORNER`]): one B-spline wall per segment through evenly spaced,
|
||||
/// rotation-minimising stations, neighbouring walls sharing their seam edges; no caps. `None`
|
||||
/// for a path with corners (see [`ruled_sheet`]).
|
||||
pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Option<Vec<Side>>> {
|
||||
pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &SweepControl) -> Result<Option<Vec<Side>>> {
|
||||
ctl.check()?;
|
||||
let pts = clean(path)?;
|
||||
if pts.windows(3).any(|w| turn(w[1] - w[0], w[2] - w[1]) > CORNER) {
|
||||
return Ok(None);
|
||||
@@ -258,10 +468,10 @@ pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -
|
||||
if chains.iter().map(Vec::len).sum::<usize>() > 4096 {
|
||||
return Err(fail("too many curves for a smooth sweep"));
|
||||
}
|
||||
let n = (pts.len() - 1).clamp(4, MAX_SMOOTH_STATIONS);
|
||||
let n = smooth_count(pts.len(), ctl);
|
||||
let sp = resample(&pts, n);
|
||||
let tangents = central_tangents(&sp)?;
|
||||
let st = stations(&sp, &tangents, &[])?;
|
||||
let st = if ctl.is_default() { stations(&sp, &tangents, &[])? } else { controlled_stations(plane, &sp, &tangents, &[], ctl)? };
|
||||
let s0 = st.first().map(|s| s.at).unwrap_or_default();
|
||||
let at = |i: usize, q: Vec2| st.get(i).map(|s| s.at + s.apply(plane.to_world(q) - s0)).unwrap_or_default();
|
||||
let n = st.len() - 1;
|
||||
@@ -340,8 +550,8 @@ pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -
|
||||
|
||||
/// Ruled walls between stations at the path's points (mitred at corners), every loop of the
|
||||
/// (oriented) profile.
|
||||
fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result<Body> {
|
||||
let (pts, st) = ruled_stations(pts)?;
|
||||
fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3], ctl: &SweepControl) -> Result<Body> {
|
||||
let (pts, st) = ruled_stations(plane, pts, ctl)?;
|
||||
let s0 = pts.first().copied().unwrap_or_default();
|
||||
let loops: Vec<&NumberedLoop> = profile.loops().collect();
|
||||
let mut wires: Vec<Vec<mt::Wire>> = Vec::with_capacity(st.len());
|
||||
@@ -438,9 +648,11 @@ fn smooth_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], expected:
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
|
||||
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
|
||||
|
||||
use super::sweep_path;
|
||||
use super::{SweepControl, SweepOrientation, sweep_path, sweep_path_with};
|
||||
|
||||
fn circle(r: f64) -> Region2 {
|
||||
Region2 { outer: Loop2::circle(Vec2::ZERO, r), holes: Vec::new() }
|
||||
@@ -476,4 +688,163 @@ mod tests {
|
||||
assert!(sweep_path(&plane, ®ion, &[Vec3::ZERO]).is_err());
|
||||
assert!(sweep_path(&plane, ®ion, &[Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]).is_err());
|
||||
}
|
||||
|
||||
fn square(h: f64) -> Region2 {
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(-h, -h), Vec2::new(h, -h), Vec2::new(h, h), Vec2::new(-h, h)]), holes: Vec::new() }
|
||||
}
|
||||
|
||||
fn up(h: f64) -> [crate::PathSeg; 1] {
|
||||
[crate::PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, h) }]
|
||||
}
|
||||
|
||||
/// A circle swept along a line to an end scale is a cone frustum: its volume and lateral
|
||||
/// area are analytic, for the solid and for the surface sweep.
|
||||
#[test]
|
||||
fn scaled_sweep_is_a_frustum() {
|
||||
let (big, h, k) = (10.0, 40.0, 0.5);
|
||||
let small = big * k;
|
||||
let ctl = SweepControl { scale: k, ..SweepControl::default() };
|
||||
let b = crate::sweep_with(&Plane::XY, &circle(big), &up(h), &ctl).unwrap();
|
||||
let m = b.tessellate(0.005).unwrap().measure();
|
||||
let volume = PI * h * (big * big + big * small + small * small) / 3.0;
|
||||
let lateral = PI * (big + small) * (h * h + (big - small).powi(2)).sqrt();
|
||||
let caps = PI * (big * big + small * small);
|
||||
assert!((m.volume - volume).abs() < 2e-3 * volume, "volume {} vs {volume}", m.volume);
|
||||
assert!((m.area - caps - lateral).abs() < 2e-3 * lateral, "lateral {} vs {lateral}", m.area - caps);
|
||||
// The end cap is the scaled circle.
|
||||
let top = b.tessellate(0.005).unwrap().positions.iter().filter(|p| (p.z - h).abs() < 1e-6).map(|p| p.x.hypot(p.y)).fold(0.0, f64::max);
|
||||
assert!((top - small).abs() < 1e-3, "end radius {top}");
|
||||
// The surface sweep of the circle's curve: the frustum's lateral face alone.
|
||||
let sheet = crate::sweep_sheet_with(&Plane::XY, std::slice::from_ref(&circle(big).outer.segs), &up(h), &ctl).unwrap();
|
||||
let a = crate::measure(&sheet).unwrap().area;
|
||||
assert!((a - lateral).abs() < 2e-3 * lateral, "sheet {a} vs {lateral}");
|
||||
}
|
||||
|
||||
/// The corners of a twisted square: each seam edge's angle at `z`.
|
||||
fn corner_angles(b: &crate::Body, z: f64) -> Vec<f64> {
|
||||
let mesh = b.tessellate(0.002).unwrap();
|
||||
let mut out = Vec::new();
|
||||
for e in &mesh.edges {
|
||||
for w in e.windows(2) {
|
||||
let (a, c) = (w[0], w[1]);
|
||||
if (a.z - z) * (c.z - z) <= 0.0 && (a.z - c.z).abs() > 1e-9 {
|
||||
let p = a.lerp(c, (z - a.z) / (c.z - a.z));
|
||||
out.push(p.y.atan2(p.x));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A square swept along a line with a twist: the section halfway along is turned by half the
|
||||
/// twist, the end by all of it; the volume is the prism's.
|
||||
#[test]
|
||||
fn twisted_sweep_turns_in_proportion() {
|
||||
let (h, twist) = (40.0, 90f64.to_radians());
|
||||
let ctl = SweepControl { twist, ..SweepControl::default() };
|
||||
let b = crate::sweep_with(&Plane::XY, &square(5.0), &up(h), &ctl).unwrap();
|
||||
// Twisted walls are saddles: a fine mesh to measure them.
|
||||
let v = b.tessellate(0.0005).unwrap().measure().volume;
|
||||
assert!((v - 100.0 * h).abs() < 2e-3 * 100.0 * h, "volume {v}");
|
||||
// Corners start at 45 deg (and 135, -45, -135): each seam turns by the twist so far.
|
||||
let off = |a: f64, want: f64| {
|
||||
let d = (a - want).rem_euclid(FRAC_PI_2);
|
||||
d.min(FRAC_PI_2 - d)
|
||||
};
|
||||
for (z, turned) in [(h / 2.0, twist / 2.0), (h, twist), (h / 4.0, twist / 4.0)] {
|
||||
let angles = corner_angles(&b, z);
|
||||
assert!(angles.len() >= 4, "seams at {z}: {angles:?}");
|
||||
for a in &angles {
|
||||
assert!(off(*a, FRAC_PI_4 + turned) < 0.2f64.to_radians(), "at {z}: {} vs {}", a.to_degrees(), (FRAC_PI_4 + turned).to_degrees());
|
||||
}
|
||||
}
|
||||
// Right hand about the direction of travel: a positive twist going up turns
|
||||
// counter-clockwise seen from above (the corner at 45 deg reaches 90 deg + 45 deg).
|
||||
let end = corner_angles(&b, h);
|
||||
assert!(end.iter().any(|a| (a - (FRAC_PI_4 + twist)).abs() < 0.2f64.to_radians()), "{end:?}");
|
||||
// One wall per side and two caps, as an untwisted sweep.
|
||||
assert_eq!(b.faces(0.01).unwrap().len(), 6);
|
||||
}
|
||||
|
||||
/// Parallel keeps the profile as drawn along a curved path: the volume is its area times
|
||||
/// the path's rise across the profile's plane.
|
||||
#[test]
|
||||
fn parallel_sweep_keeps_the_profile_square_to_its_normal() {
|
||||
// An eighth of a circle: the path ends leaning 45 deg from the profile's normal.
|
||||
let r = 30.0;
|
||||
let path: Vec<Vec3> = (0..=60)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 60.0 * FRAC_PI_4;
|
||||
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
|
||||
})
|
||||
.collect();
|
||||
let rise = r * FRAC_PI_4.sin();
|
||||
let ctl = SweepControl { orientation: SweepOrientation::Parallel, ..SweepControl::default() };
|
||||
let b = sweep_path_with(&Plane::XY, &square(3.0), &path, &ctl).unwrap();
|
||||
let mesh = b.tessellate(0.005).unwrap();
|
||||
let v = mesh.measure().volume;
|
||||
let want = 36.0 * rise;
|
||||
assert!((v - want).abs() < 5e-3 * want, "volume {v} vs {want}");
|
||||
// The end cap is still horizontal: the body tops out flat at the path's end.
|
||||
let top: Vec<&Vec3> = mesh.positions.iter().filter(|p| (p.z - rise).abs() < 1e-6).collect();
|
||||
assert!(top.len() >= 4, "flat top");
|
||||
assert!(mesh.positions.iter().all(|p| p.z <= rise + 1e-6));
|
||||
// A path that turns to run along the profile's plane (a quarter circle) is refused.
|
||||
let quarter: Vec<Vec3> = (0..=60)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 60.0 * FRAC_PI_2;
|
||||
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
|
||||
})
|
||||
.collect();
|
||||
assert!(sweep_path_with(&Plane::XY, &square(3.0), &quarter, &ctl).is_err());
|
||||
}
|
||||
|
||||
/// A direction keeps the square's sides lined up with it along a curved path; in a plane
|
||||
/// square to the direction, that is the rotation-minimising frame too.
|
||||
#[test]
|
||||
fn direction_orientation_follows_the_vector() {
|
||||
let r = 30.0;
|
||||
let path: Vec<Vec3> = (0..=60)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 60.0 * FRAC_PI_2;
|
||||
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
|
||||
})
|
||||
.collect();
|
||||
let ctl = SweepControl { orientation: SweepOrientation::Direction(Vec3::Y), ..SweepControl::default() };
|
||||
let b = sweep_path_with(&Plane::XY, &square(3.0), &path, &ctl).unwrap();
|
||||
let mesh = b.tessellate(0.005).unwrap();
|
||||
let len = r * FRAC_PI_2;
|
||||
assert!((mesh.measure().volume - 36.0 * len).abs() < 5e-3 * 36.0 * len);
|
||||
// The sides facing ±Y stay at y = ±3.
|
||||
assert!(mesh.positions.iter().all(|p| p.y.abs() <= 3.0 + 1e-6));
|
||||
// The path running along the direction is refused.
|
||||
let along = SweepControl { orientation: SweepOrientation::Direction(Vec3::Z), ..SweepControl::default() };
|
||||
assert!(crate::sweep_with(&Plane::XY, &square(3.0), &up(10.0), &along).is_err());
|
||||
}
|
||||
|
||||
/// Out-of-range options are refused, never a panic; the default options are a plain sweep.
|
||||
#[test]
|
||||
fn sweep_options_refuse_hostile_values() {
|
||||
let bad = [
|
||||
SweepControl { twist: f64::NAN, ..SweepControl::default() },
|
||||
SweepControl { twist: 1e9, ..SweepControl::default() },
|
||||
SweepControl { twist: SweepControl::MAX_TWIST * 1.01, ..SweepControl::default() },
|
||||
SweepControl { scale: 0.0, ..SweepControl::default() },
|
||||
SweepControl { scale: -1.0, ..SweepControl::default() },
|
||||
SweepControl { scale: f64::INFINITY, ..SweepControl::default() },
|
||||
SweepControl { scale: 1e6, ..SweepControl::default() },
|
||||
SweepControl { orientation: SweepOrientation::Direction(Vec3::ZERO), ..SweepControl::default() },
|
||||
SweepControl { orientation: SweepOrientation::Direction(Vec3::new(f64::NAN, 0.0, 1.0)), ..SweepControl::default() },
|
||||
];
|
||||
for ctl in bad {
|
||||
assert!(crate::sweep_with(&Plane::XY, &circle(2.0), &up(10.0), &ctl).is_err(), "{ctl:?}");
|
||||
assert!(crate::sweep_sheet_with(&Plane::XY, std::slice::from_ref(&circle(2.0).outer.segs), &up(10.0), &ctl).is_err(), "{ctl:?}");
|
||||
assert!(sweep_path_with(&Plane::XY, &circle(2.0), &[Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0)], &ctl).is_err(), "{ctl:?}");
|
||||
}
|
||||
// The largest twist still builds (a holed profile: ruled walls through dense stations).
|
||||
let holed = Region2 { outer: square(3.0).outer, holes: vec![Loop2::circle(Vec2::ZERO, 1.0).reversed()] };
|
||||
let max = SweepControl { twist: SweepControl::MAX_TWIST, scale: 0.5, ..SweepControl::default() };
|
||||
assert!(crate::sweep_with(&Plane::XY, &holed, &up(10.0), &max).is_ok());
|
||||
assert!(SweepControl::default().is_default());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user