1 parent
7ba2d5db47
commit
98ce6a4ee8
22 files changed
+804
-55
No files matched your search
@@ -1835,7 +1835,7 @@ pub(crate) fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
|
||||
|
||||
/// 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>> {
|
||||
pub 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())),
|
||||
|
||||
@@ -114,6 +114,8 @@ mod surface_ruled;
|
||||
mod surface_untrim;
|
||||
mod surfaces;
|
||||
mod sweep_path;
|
||||
#[cfg(test)]
|
||||
mod sweep_rails_tests;
|
||||
mod tangent_cylinders;
|
||||
mod tess;
|
||||
#[cfg(test)]
|
||||
@@ -134,8 +136,8 @@ pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrate
|
||||
pub use boundary::{Boundary, boundary_patch};
|
||||
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
|
||||
pub use build::{
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, revolve_whole, sphere,
|
||||
sweep, sweep_with, torus,
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve,
|
||||
revolve_whole, sphere, sweep, sweep_with, torus,
|
||||
};
|
||||
pub use continuity::{
|
||||
ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamVerdict, SeamWorst, pick_edges, seam_diagnostics,
|
||||
@@ -180,7 +182,7 @@ 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, sweep_sheet_points_with, sweep_sheet_with, thicken, trim, unstitch,
|
||||
};
|
||||
pub use sweep_path::{SweepControl, SweepOrientation, sweep_path, sweep_path_with};
|
||||
pub use sweep_path::{RailFit, SweepControl, SweepOrientation, sweep_path, sweep_path_with};
|
||||
pub use topo::{CylinderFace, cylinder_end_at, cylinder_face_at};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
pub use tspline::tspline_sheet;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Guide rails on path sweeps (issue #771), checked against analytic solids: a rail that closes
|
||||
//! in on the path scales a circle into a cone frustum, a stretching rail widens a square one way
|
||||
//! only, a helical rail twists a square, two rails carry a line between them; and rails the
|
||||
//! sweep cannot follow are refused.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
|
||||
use crate::{RailFit, SweepControl, measure, sweep_path_with};
|
||||
|
||||
fn circle(r: f64) -> Region2 {
|
||||
Region2 { outer: Loop2::circle(Vec2::ZERO, r), holes: Vec::new() }
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
/// The Z axis from 0 to 40.
|
||||
fn up() -> Vec<Vec3> {
|
||||
vec![Vec3::ZERO, Vec3::new(0.0, 0.0, 40.0)]
|
||||
}
|
||||
|
||||
fn railed(rails: Vec<Vec<Vec3>>, fit: RailFit) -> SweepControl {
|
||||
SweepControl { rails, rail_fit: fit, ..SweepControl::default() }
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64, rel: f64) -> bool {
|
||||
(a - b).abs() <= rel * b.abs()
|
||||
}
|
||||
|
||||
/// A rail from the circle's edge at radius 10 in to radius 5 at the top scales the circle into
|
||||
/// a frustum: volume πh(R² + Rr + r²)/3, lateral area π(R + r) times the slant height.
|
||||
#[test]
|
||||
fn a_rail_closing_in_scales_a_circle_into_a_frustum() {
|
||||
let rail = vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 40.0)];
|
||||
let b = sweep_path_with(&Plane::XY, &circle(10.0), &up(), &railed(vec![rail], RailFit::Scale)).unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
let volume = PI * 40.0 / 3.0 * (100.0 + 50.0 + 25.0);
|
||||
let area = PI * 15.0 * (40.0f64 * 40.0 + 25.0).sqrt() + PI * 100.0 + PI * 25.0;
|
||||
assert!(close(m.volume, volume, 2e-3), "volume {} vs {volume}", m.volume);
|
||||
assert!(close(m.area, area, 2e-3), "area {} vs {area}", m.area);
|
||||
assert!(close(m.bbox.max.z, 40.0, 1e-9), "{:?}", m.bbox);
|
||||
}
|
||||
|
||||
/// A stretching rail from x = 5 out to x = 10 doubles the square's width along X by the top and
|
||||
/// leaves its depth along Y: sections 10 × 10k with k = 1 + z/40, volume 100 · 40 · 1.5.
|
||||
#[test]
|
||||
fn a_stretching_rail_widens_a_square_one_way() {
|
||||
let rail = vec![Vec3::new(5.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 40.0)];
|
||||
let b = sweep_path_with(&Plane::XY, &square(5.0), &up(), &railed(vec![rail], RailFit::Stretch)).unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
assert!(close(m.volume, 6000.0, 1e-3), "volume {}", m.volume);
|
||||
assert!((m.bbox.max.x - 10.0).abs() < 1e-3 && (m.bbox.min.x + 10.0).abs() < 1e-3, "{:?}", m.bbox);
|
||||
assert!((m.bbox.max.y - 5.0).abs() < 1e-3 && (m.bbox.min.y + 5.0).abs() < 1e-3, "{:?}", m.bbox);
|
||||
}
|
||||
|
||||
/// The same rail scaling evenly: the square grows both ways, sections 100k², volume
|
||||
/// 100 ∫ (1 + z/40)² dz = 100 · 40 · 7/3.
|
||||
#[test]
|
||||
fn a_scaling_rail_grows_a_square_both_ways() {
|
||||
let rail = vec![Vec3::new(5.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 40.0)];
|
||||
let b = sweep_path_with(&Plane::XY, &square(5.0), &up(), &railed(vec![rail], RailFit::Scale)).unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
let volume = 100.0 * 40.0 * 7.0 / 3.0;
|
||||
assert!(close(m.volume, volume, 2e-3), "volume {} vs {volume}", m.volume);
|
||||
assert!((m.bbox.max.y - 10.0).abs() < 1e-3, "{:?}", m.bbox);
|
||||
}
|
||||
|
||||
/// A helical rail from the square's corner, a quarter turn about the path at the corner's
|
||||
/// radius, twists the square a quarter turn: the volume stays 4000, halfway up the corners stand
|
||||
/// on the axes (the box reaches 5√2), and at the top the corner is at (−5, 5).
|
||||
#[test]
|
||||
fn a_helical_rail_twists_a_square() {
|
||||
let r = 50f64.sqrt();
|
||||
let rail: Vec<Vec3> = (0..=200)
|
||||
.map(|i| {
|
||||
let u = i as f64 / 200.0;
|
||||
let a = PI / 4.0 + u * PI / 2.0;
|
||||
Vec3::new(r * a.cos(), r * a.sin(), 40.0 * u)
|
||||
})
|
||||
.collect();
|
||||
let b = sweep_path_with(&Plane::XY, &square(5.0), &up(), &railed(vec![rail], RailFit::Scale)).unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
assert!(close(m.volume, 4000.0, 2e-3), "volume {}", m.volume);
|
||||
assert!((m.bbox.max.x - r).abs() < 0.05 && (m.bbox.max.y - r).abs() < 0.05, "{:?}", m.bbox);
|
||||
// The edge running up from the corner (5, 5) ends a quarter turn on, at (−5, 5).
|
||||
let edges = b.edges(0.01).unwrap();
|
||||
let ends = |e: &crate::EdgeInfo| (e.points.first().copied().unwrap(), e.points.last().copied().unwrap());
|
||||
let joins = |p: Vec3, q: Vec3| {
|
||||
edges.iter().any(|e| {
|
||||
let (a, b) = ends(e);
|
||||
(a.dist(p) < 1e-3 && b.dist(q) < 1e-3) || (a.dist(q) < 1e-3 && b.dist(p) < 1e-3)
|
||||
})
|
||||
};
|
||||
assert!(joins(Vec3::new(5.0, 5.0, 0.0), Vec3::new(-5.0, 5.0, 40.0)), "{:?}", edges.iter().map(ends).collect::<Vec<_>>());
|
||||
assert!(!joins(Vec3::new(5.0, 5.0, 0.0), Vec3::new(5.0, 5.0, 40.0)));
|
||||
}
|
||||
|
||||
/// Along a quarter circle of radius 30, a rail keeping 5 mm out from the path (the circle's
|
||||
/// outer point, on a quarter circle of radius 35) leaves the circle as it is: a quarter torus,
|
||||
/// volume πr² · R · π/2 (Pappus).
|
||||
#[test]
|
||||
fn a_parallel_rail_along_a_curved_path_keeps_the_section() {
|
||||
let arc = |radius: f64| -> Vec<Vec3> {
|
||||
(0..=180)
|
||||
.map(|i| {
|
||||
let a = PI / 2.0 * i as f64 / 180.0;
|
||||
Vec3::new(30.0 - radius * a.cos(), 0.0, radius * a.sin())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let b = sweep_path_with(&Plane::XY, &circle(5.0), &arc(30.0), &railed(vec![arc(35.0)], RailFit::Scale)).unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
let volume = PI * 25.0 * 30.0 * PI / 2.0;
|
||||
assert!(close(m.volume, volume, 2e-3), "volume {} vs {volume}", m.volume);
|
||||
}
|
||||
|
||||
/// Two rails bulging apart along arcs (radius 25 about (∓5, 0, 20), through x = ±10 at z = 0
|
||||
/// and z = 40) carry a line between them: the sheet lies in the XZ plane between the arcs, area
|
||||
/// 2(a√(R² − a²) + R² asin(a/R)) − 400 with a = 20.
|
||||
#[test]
|
||||
fn two_rails_carry_a_line_between_arcs() {
|
||||
let arc = |side: f64| -> Vec<Vec3> {
|
||||
(0..=400)
|
||||
.map(|i| {
|
||||
let z = 40.0 * i as f64 / 400.0;
|
||||
let x = -5.0 + (625.0 - (z - 20.0) * (z - 20.0)).sqrt();
|
||||
Vec3::new(side * x, 0.0, z)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let line = vec![vec![Seg2::Line { a: Vec2::new(-10.0, 0.0), b: Vec2::new(10.0, 0.0) }]];
|
||||
for fit in [RailFit::Scale, RailFit::Stretch] {
|
||||
let ctl = railed(vec![arc(-1.0), arc(1.0)], fit);
|
||||
let b = crate::sweep_sheet_points_with(&Plane::XY, &line, &up(), &ctl).unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
let area = 2.0 * (20.0 * 15.0 + 625.0 * (0.8f64).asin()) - 400.0;
|
||||
assert!(close(m.area, area, 1e-3), "{fit:?}: area {} vs {area}", m.area);
|
||||
assert!(m.bbox.min.y.abs() < 1e-6 && m.bbox.max.y.abs() < 1e-6, "{fit:?}: {:?}", m.bbox);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rails the sweep cannot follow are refused with a reason, never a panic: three rails, a rail
|
||||
/// with a bad point, a rail with a twist or scale besides, a rail starting on the path, a rail
|
||||
/// that stops short, rails along a path with a corner.
|
||||
#[test]
|
||||
fn rails_the_sweep_cannot_follow_are_refused() {
|
||||
let rail = vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 40.0)];
|
||||
let refuse = |ctl: SweepControl, path: &[Vec3], why: &str| {
|
||||
let e = sweep_path_with(&Plane::XY, &circle(10.0), path, &ctl).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "expected `{why}` in `{e}`");
|
||||
};
|
||||
refuse(railed(vec![rail.clone(); 3], RailFit::Scale), &up(), "one or two guide rails");
|
||||
refuse(railed(vec![vec![Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0)]], RailFit::Scale), &up(), "finite points");
|
||||
refuse(SweepControl { twist: 1.0, ..railed(vec![rail.clone()], RailFit::Scale) }, &up(), "leave the orientation");
|
||||
refuse(SweepControl { scale: 2.0, ..railed(vec![rail.clone()], RailFit::Scale) }, &up(), "leave the orientation");
|
||||
refuse(railed(vec![vec![Vec3::ZERO, Vec3::new(5.0, 0.0, 40.0)]], RailFit::Scale), &up(), "starts on the path");
|
||||
refuse(railed(vec![vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(8.0, 0.0, 20.0)]], RailFit::Scale), &up(), "whole length");
|
||||
refuse(railed(vec![rail.clone(), rail.clone()], RailFit::Scale), &up(), "start at the same point");
|
||||
let bent = vec![Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::new(20.0, 0.0, 20.0)];
|
||||
refuse(railed(vec![vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 30.0)]], RailFit::Scale), &bent, "sharp corners");
|
||||
}
|
||||
Reference in new issue
Block a user