mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
kernel: SMLib sweeps round corners and along free-form paths, lofts with holes or to a point, drafts on a foot
Through SMLib, in place of "not supported yet": - A sweep along a path with corners: each smooth run swept on its own and the runs joined at each corner in a mitre (each run swept on straight past the corner and cut back to the plane halving it, where the next meets it face to face), including the corner where a closed path closes; only moving along the path, the runs meet on the profile. History: walls from their segments (found half-way along each run), caps where the path ends. - A free-form path curve (a spline's Bézier pieces, conics) reaches the kernel as PathSeg::Curve, its exact piece, instead of the sketch's polyline of lines: SMLib sweeps along the curve itself; truck's sweeps flatten it as before (now the piece's own polyline within 0.01 mm, which turns ladder 04/004 into a pass for truck too). Patterns along a path flatten it and measure as before. A sweep that twists, scales or follows rails places its stations by length along the curve. (Such a controlled sweep still refuses a path with corners.) - A loft through sections with holes: each section's outline and holes are one SMLib profile, the holes matched as for truck. - A loft to a point: SMLib's pyramid (smlib_loft_to_point). - A draft extrusion with a straight foot: the foot's prism and the drafted part (sloping from the foot's top to the far end the taper gives) joined face to face; each segment names its foot and its sloping wall. Cases now passing on SMLib: booleans/3633, sweeps/1184, 2435, 3111, 3006, 3505, 561, 96, ladder 04/003 and 004. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
+16
-17
@@ -5675,14 +5675,7 @@ fn feature_tools_with_constructor(
|
||||
Some(_) => Vec::new(),
|
||||
None => path_segments(ps, path, start)?,
|
||||
};
|
||||
let path_start = points
|
||||
.as_ref()
|
||||
.and_then(|p| p.first().copied())
|
||||
.or_else(|| match segs.first() {
|
||||
Some(kernel::PathSeg::Line { a, .. } | kernel::PathSeg::Arc { a, .. }) => Some(*a),
|
||||
None => None,
|
||||
})
|
||||
.unwrap_or(start);
|
||||
let path_start = points.as_ref().and_then(|p| p.first().copied()).or_else(|| segs.first().map(kernel::PathSeg::start)).unwrap_or(start);
|
||||
let rails = rail_polylines(doc, st, rails, f.component, path_start)?;
|
||||
let ctl = sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?;
|
||||
// A path through 3D sketch curves follows them as points.
|
||||
@@ -5708,11 +5701,8 @@ fn feature_tools_with_constructor(
|
||||
}
|
||||
let segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?;
|
||||
let first = segs.first().ok_or_else(|| DocError::Invalid("empty path".into()))?;
|
||||
let (start, tangent) = match *first {
|
||||
kernel::PathSeg::Line { a, b } => (a, b - a),
|
||||
kernel::PathSeg::Arc { a, center, axis, .. } => (a, axis.cross(a - center)),
|
||||
};
|
||||
let t = tangent.normalized().ok_or_else(|| DocError::Invalid("path direction".into()))?;
|
||||
let start = first.start();
|
||||
let (t, _) = first.tangents().ok_or_else(|| DocError::Invalid("path direction".into()))?;
|
||||
let plane = Plane::from_normal(start, t).ok_or_else(|| DocError::Invalid("pipe profile plane".into()))?;
|
||||
Ok(unnamed(vec![kernel::sweep(&plane, &pipe_region(vals, r, wall)?, &segs)?]))
|
||||
}
|
||||
@@ -5959,7 +5949,8 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<k
|
||||
let mut segs: Vec<solidcraft_geom::Seg2> = Vec::new();
|
||||
for id in ids {
|
||||
let ci = ps.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("path curve `{id}`")))?;
|
||||
segs.extend(ps.sketch.segs(ci));
|
||||
// A free-form curve as its exact pieces (Béziers, conics) where it has them.
|
||||
segs.extend(ps.sketch.exact_segs(ci).unwrap_or_else(|| ps.sketch.segs(ci)));
|
||||
}
|
||||
// Orient the chain: the first segment starts at the end nearest `start`, each next one
|
||||
// starts where the previous ended.
|
||||
@@ -5994,9 +5985,8 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<k
|
||||
solidcraft_geom::Seg2::Arc { center, sweep, .. } => {
|
||||
vec![kernel::PathSeg::Arc { a: w(s.start()), center: w(center), axis: n * sweep.signum(), angle: sweep.abs() }]
|
||||
}
|
||||
// Free-form path curves are followed as polylines.
|
||||
solidcraft_geom::Seg2::Cubic { .. } | solidcraft_geom::Seg2::Conic { .. } | solidcraft_geom::Seg2::Bezier { .. } => {
|
||||
s.polyline(1e-2).windows(2).map(|q| kernel::PathSeg::Line { a: w(q[0]), b: w(q[1]) }).collect()
|
||||
vec![kernel::PathSeg::Curve { plane: ps.plane, seg: *s }]
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
@@ -6205,7 +6195,8 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &Model
|
||||
let ps = st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("path sketch {path_sketch}")))?;
|
||||
let mapped = path_in_frame(doc, ps, component)?;
|
||||
let ps = mapped.as_ref();
|
||||
let mut segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?;
|
||||
// Free-form curves measured along their polylines.
|
||||
let mut segs = kernel::PathSeg::flattened(&path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?);
|
||||
if *flip {
|
||||
segs = segs.iter().rev().map(reverse_seg).collect();
|
||||
}
|
||||
@@ -6261,6 +6252,8 @@ fn seg_len(s: &kernel::PathSeg) -> f64 {
|
||||
match *s {
|
||||
kernel::PathSeg::Line { a, b } => (b - a).len(),
|
||||
kernel::PathSeg::Arc { a, center, angle, .. } => (a - center).len() * angle.abs(),
|
||||
// Patterns flatten their paths; a curve left is measured along its chord.
|
||||
kernel::PathSeg::Curve { plane, seg } => plane.to_world(seg.start()).dist(plane.to_world(seg.end())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6284,6 +6277,11 @@ fn seg_at(s: &kernel::PathSeg, t: f64) -> (Vec3, Vec3) {
|
||||
let v = rotate_vec(a - center, k, s * t / r);
|
||||
(center + v, k.cross(v).normalized().unwrap_or(Vec3::X) * s)
|
||||
}
|
||||
kernel::PathSeg::Curve { plane, seg } => {
|
||||
let (a, b) = (plane.to_world(seg.start()), plane.to_world(seg.end()));
|
||||
let d = (b - a).normalized().unwrap_or(Vec3::X);
|
||||
(a + d * t, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6295,6 +6293,7 @@ fn reverse_seg(s: &kernel::PathSeg) -> kernel::PathSeg {
|
||||
let end = center + rotate_vec(a - center, k, angle);
|
||||
kernel::PathSeg::Arc { a: end, center, axis: k * -1.0, angle }
|
||||
}
|
||||
kernel::PathSeg::Curve { plane, seg } => kernel::PathSeg::Curve { plane, seg: seg.reversed() },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -378,10 +378,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
None => {
|
||||
let segs = path_segments(ps, path, start)?;
|
||||
let path_start = match segs.first() {
|
||||
Some(kernel::PathSeg::Line { a, .. } | kernel::PathSeg::Arc { a, .. }) => *a,
|
||||
None => start,
|
||||
};
|
||||
let path_start = segs.first().map_or(start, kernel::PathSeg::start);
|
||||
let rails = crate::eval::rail_polylines(doc, st, rails, f.component, path_start)?;
|
||||
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?;
|
||||
kernel::sweep_sheet_with(&ss.plane, &chains, &segs, &ctl)?
|
||||
|
||||
+82
-21
@@ -1382,11 +1382,8 @@ pub fn extrude_tapered_between(plane: &Plane, region: &Region2, from: f64, to: f
|
||||
}
|
||||
|
||||
/// [`extrude_tapered_between`] through SMLib's draft extrusion: one loop (SMLib's takes no
|
||||
/// holes), its section at `from` the profile offset by the taper, no straight foot.
|
||||
/// holes), its section at `from` the profile offset by the taper, after any straight foot.
|
||||
fn smlib_tapered(plane: &Plane, region: &Region2, from: f64, to: f64, foot: f64, sign: f64, taper: f64) -> Result<Body> {
|
||||
if foot > 0.0 {
|
||||
return Err(crate::smlib_sweeps::refuse("draft extrusion with a straight foot"));
|
||||
}
|
||||
if !region.holes.is_empty() {
|
||||
return Err(crate::smlib_sweeps::refuse("draft extrusion of a profile with holes (SMLib's takes one loop)"));
|
||||
}
|
||||
@@ -1399,7 +1396,7 @@ fn smlib_tapered(plane: &Plane, region: &Region2, from: f64, to: f64, foot: f64,
|
||||
let segs = segs.ok_or_else(|| crate::smlib_sweeps::refuse("draft extrusion that starts before its profile where the taper closes a side"))?;
|
||||
NumberedLoop { lp: Loop2 { segs }, from: outer.from }
|
||||
};
|
||||
let draft = (taper != 0.0).then_some(crate::smlib_sweeps::Draft { taper });
|
||||
let draft = (taper != 0.0).then_some(crate::smlib_sweeps::Draft { taper, foot });
|
||||
crate::smlib_sweeps::extrude(plane, &crate::smlib_sweeps::pieces([&bottom]), Some(region), from * sign, to - from, sign, draft)
|
||||
}
|
||||
|
||||
@@ -1933,9 +1930,6 @@ pub fn loft_regions(sections: &[(Plane, Region2)]) -> Result<Body> {
|
||||
if holes == 0 {
|
||||
return loft(§ions.iter().map(|(p, r)| (*p, r.outer.clone())).collect::<Vec<_>>());
|
||||
}
|
||||
if crate::backend()? == crate::Backend::Smlib {
|
||||
return Err(crate::smlib_sweeps::refuse("loft through sections with holes"));
|
||||
}
|
||||
let regions: Vec<Region2> = sections.iter().map(|(_, r)| r.clone()).collect();
|
||||
let mut numbered = numbered::number_regions(®ions);
|
||||
let outlines: Vec<_> = sections.iter().map(|(p, _)| *p).zip(numbered.iter().map(|r| r.outer.clone())).collect();
|
||||
@@ -1993,6 +1987,9 @@ pub fn loft_regions(sections: &[(Plane, Region2)]) -> Result<Body> {
|
||||
if counts.iter().sum::<usize>() > MAX_SEGS {
|
||||
return Err(KernelError::Invalid("matching loft sections would create too many segments".into()));
|
||||
}
|
||||
if crate::backend()? == crate::Backend::Smlib {
|
||||
return smlib_loft_regions(&outlines, &planes, &numbered, reversed);
|
||||
}
|
||||
guard("loft with holes", || {
|
||||
let mut loops = vec![loft_numbered_wires(&outlines, reversed)?];
|
||||
for i in 0..holes {
|
||||
@@ -2032,6 +2029,32 @@ pub fn loft_regions(sections: &[(Plane, Region2)]) -> Result<Body> {
|
||||
})
|
||||
}
|
||||
|
||||
/// [`loft_regions`] through SMLib's skin, which lofts a section's loops (its outline, then its
|
||||
/// holes, in the order given) to the loops in the same place in the next: the outline and each
|
||||
/// hole matched as for truck, every section's loops one profile, the holes turned against the
|
||||
/// outline. `outlines` are as given, `planes` and the holes of `numbered` in canonical order.
|
||||
fn smlib_loft_regions(outlines: &[(Plane, NumberedLoop)], planes: &[Plane], numbered: &[NumberedRegion], reversed: bool) -> Result<Body> {
|
||||
let mut sections: Vec<(Plane, Vec<crate::smlib_sweeps::Piece>)> =
|
||||
loft_numbered_loops(outlines, reversed)?.iter().map(|(p, l)| (*p, crate::smlib_sweeps::pieces([l]))).collect();
|
||||
let holes = numbered.first().map_or(0, |r| r.holes.len());
|
||||
for i in 0..holes {
|
||||
let secs = planes
|
||||
.iter()
|
||||
.zip(numbered)
|
||||
.map(|(p, r)| r.holes.get(i).cloned().map(|h| (*p, h)).ok_or_else(|| KernelError::Invalid("missing loft hole".into())))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let hole = loft_numbered_loops(&secs, false)?;
|
||||
if hole.len() != sections.len() {
|
||||
return Err(KernelError::Invalid("missing loft hole".into()));
|
||||
}
|
||||
for ((_, pieces), (_, h)) in sections.iter_mut().zip(&hole) {
|
||||
pieces.extend(crate::smlib_sweeps::pieces([&h.reversed()]));
|
||||
}
|
||||
}
|
||||
let first = if reversed { sections.len().saturating_sub(1) } else { 0 };
|
||||
crate::smlib_sweeps::loft(§ions, first)
|
||||
}
|
||||
|
||||
/// The wires of a loft's closed sections, matched: oriented alike, resampled to the same
|
||||
/// segment count and turned to start alike; with the profile segments under each wire edge, and
|
||||
/// whether the sections were turned round into the canonical order.
|
||||
@@ -2309,11 +2332,24 @@ pub enum PathSeg {
|
||||
axis: Vec3,
|
||||
angle: f64,
|
||||
},
|
||||
/// A free-form curve of a sketch (a Bézier or conic piece): `seg` on `plane`.
|
||||
Curve {
|
||||
plane: Plane,
|
||||
seg: Seg2,
|
||||
},
|
||||
}
|
||||
|
||||
impl PathSeg {
|
||||
/// Where the segment starts.
|
||||
pub fn start(&self) -> Vec3 {
|
||||
match *self {
|
||||
PathSeg::Line { a, .. } | PathSeg::Arc { a, .. } => a,
|
||||
PathSeg::Curve { plane, seg } => plane.to_world(seg.start()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit directions of the path where the segment starts and ends.
|
||||
pub(crate) fn tangents(&self) -> Option<(Vec3, Vec3)> {
|
||||
pub fn tangents(&self) -> Option<(Vec3, Vec3)> {
|
||||
match *self {
|
||||
PathSeg::Line { a, b } => {
|
||||
let t = (b - a).normalized()?;
|
||||
@@ -2324,13 +2360,30 @@ impl PathSeg {
|
||||
let end = rotate_about(a, center, k, angle);
|
||||
Some((k.cross(a - center).normalized()?, k.cross(end - center).normalized()?))
|
||||
}
|
||||
PathSeg::Curve { plane, seg } => {
|
||||
Some((plane.dir_to_world(seg.start_tangent()).normalized()?, plane.dir_to_world(seg.end_tangent()).normalized()?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The path with each free-form curve as the lines of its polyline within 0.01 mm, as
|
||||
/// truck's sweeps follow it.
|
||||
pub fn flattened(path: &[PathSeg]) -> Vec<PathSeg> {
|
||||
path.iter()
|
||||
.flat_map(|s| match *s {
|
||||
PathSeg::Curve { plane, seg } => {
|
||||
seg.polyline(1e-2).windows(2).map(|q| PathSeg::Line { a: plane.to_world(q[0]), b: plane.to_world(q[1]) }).collect()
|
||||
}
|
||||
_ => vec![*s],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Points along the segment (an arc within `tol` of its chords), without its start.
|
||||
pub(crate) fn points_after_start(&self, tol: f64) -> Vec<Vec3> {
|
||||
match *self {
|
||||
PathSeg::Line { b, .. } => vec![b],
|
||||
PathSeg::Curve { plane, seg } => seg.polyline(tol).into_iter().skip(1).map(|q| plane.to_world(q)).collect(),
|
||||
PathSeg::Arc { a, center, axis, angle } => {
|
||||
let Some(k) = axis.normalized() else { return vec![a] };
|
||||
let r = (a - center).len();
|
||||
@@ -2349,20 +2402,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, 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`]).
|
||||
/// [`sweep_with`] through SMLib: its curve sweep for the profile turning with the path or only
|
||||
/// moving along it (orientation parallel), the walls meeting in mitres at the path's corners; a
|
||||
/// twist, an end scale, guide rails or an orientation direction place the profile at stations
|
||||
/// along a path without corners ([`crate::smlib_sweeps::sweep_controlled`]).
|
||||
fn smlib_sweep(plane: &Plane, region: &Region2, path: &[PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
|
||||
ctl.check()?;
|
||||
region_ok(region)?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
}
|
||||
let ends: Vec<(Vec3, Vec3)> =
|
||||
path.iter().map(PathSeg::tangents).collect::<Option<_>>().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)"));
|
||||
if path.iter().any(|s| s.tangents().is_none()) {
|
||||
return Err(KernelError::Invalid("a path segment has no direction".into()));
|
||||
}
|
||||
let controlled = ctl.varies() || matches!(ctl.orientation, crate::SweepOrientation::Direction(_));
|
||||
if controlled {
|
||||
let ends: Vec<(Vec3, Vec3)> = path.iter().filter_map(PathSeg::tangents).collect();
|
||||
if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) {
|
||||
return Err(crate::smlib_sweeps::refuse(
|
||||
"sweep with a twist, an end scale, guide rails or an orientation direction along a path with corners",
|
||||
));
|
||||
}
|
||||
}
|
||||
let translate = match ctl.orientation {
|
||||
_ if ctl.varies() => return crate::smlib_sweeps::sweep_controlled(plane, &oriented(region)?, path, ctl),
|
||||
@@ -2376,10 +2436,7 @@ fn smlib_sweep(plane: &Plane, region: &Region2, path: &[PathSeg], ctl: &crate::S
|
||||
/// The path as a polyline: its first point, then each segment's points (arcs within 0.01 mm of
|
||||
/// their chords).
|
||||
pub fn path_polyline(path: &[PathSeg]) -> Result<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 first = path.first().map(PathSeg::start).ok_or_else(|| 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));
|
||||
@@ -2397,6 +2454,7 @@ pub fn sweep_with(plane: &Plane, region: &Region2, path: &[PathSeg], ctl: &crate
|
||||
if ctl.is_default() {
|
||||
return sweep(plane, region, path);
|
||||
}
|
||||
let path = &PathSeg::flattened(path)[..];
|
||||
ctl.check()?;
|
||||
region_ok(region)?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
@@ -2415,6 +2473,7 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
|
||||
if crate::backend()? == crate::Backend::Smlib {
|
||||
return smlib_sweep(plane, region, path, &crate::SweepControl::default());
|
||||
}
|
||||
let path = &PathSeg::flattened(path)[..];
|
||||
region_ok(region)?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
@@ -2461,6 +2520,8 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
|
||||
let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("arc axis".into()))?;
|
||||
builder::rsweep(&cur, p3(center), v3(ax), mt::Rad(angle))
|
||||
}
|
||||
// The path was flattened: no curves are left.
|
||||
PathSeg::Curve { .. } => return Err(KernelError::Invalid("a free-form path curve".into())),
|
||||
};
|
||||
let next = part
|
||||
.extract_boundaries()
|
||||
|
||||
@@ -186,9 +186,6 @@ fn lp_area_path(plane: &Plane, lp: &Loop2, o: Vec3, k: Vec3, turns: f64) -> f64
|
||||
|
||||
/// A solid from a planar loop of straight segments to an apex point off its plane (a pyramid).
|
||||
pub fn loft_to_point(plane: &Plane, lp: &Loop2, apex: Vec3) -> Result<Body> {
|
||||
if crate::backend()? == crate::Backend::Smlib {
|
||||
return Err(crate::smlib_sweeps::refuse("loft to a point"));
|
||||
}
|
||||
if !apex.is_finite() || lp.segs.is_empty() || lp.segs.len() > 10_000 {
|
||||
return Err(KernelError::Invalid("loft to a point: bad section or apex".into()));
|
||||
}
|
||||
@@ -201,6 +198,9 @@ pub fn loft_to_point(plane: &Plane, lp: &Loop2, apex: Vec3) -> Result<Body> {
|
||||
// Wind the base so its normal points away from the apex.
|
||||
let above = (apex - plane.origin).dot(plane.normal()) > 0.0;
|
||||
let base = crate::numbered::number_loops([lp]).pop().ok_or_else(|| KernelError::Invalid("loft to a point: bad section".into()))?.oriented(above);
|
||||
if crate::backend()? == crate::Backend::Smlib {
|
||||
return crate::smlib_sweeps::loft_to_point(plane, &crate::smlib_sweeps::pieces([&base]), apex);
|
||||
}
|
||||
let lp = &base.lp;
|
||||
guard("loft to point", || {
|
||||
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
|
||||
|
||||
@@ -65,10 +65,13 @@ fn not_supported(what: &str) -> KernelError {
|
||||
pub(crate) struct Draft {
|
||||
/// Radians; positive grows the profile (as [`crate::extrude_tapered`] takes it).
|
||||
pub taper: f64,
|
||||
/// A height (less than the distance) the walls first stand straight for, before they slope
|
||||
/// on to the same far end ([`crate::extrude_tapered_between`]'s foot).
|
||||
pub foot: f64,
|
||||
}
|
||||
|
||||
#[cfg(feature = "smlib")]
|
||||
pub(crate) use imp::{extrude, loft, loft_rails, revolve, screw, sweep, sweep_controlled};
|
||||
pub(crate) use imp::{extrude, loft, loft_rails, loft_to_point, revolve, screw, sweep, sweep_controlled};
|
||||
|
||||
#[cfg(not(feature = "smlib"))]
|
||||
mod stubs {
|
||||
@@ -98,9 +101,12 @@ mod stubs {
|
||||
pub(crate) fn loft_rails(_: &[(Plane, Vec<Piece>)], _: usize, _: &[Vec<Vec3>], _: crate::RailFit) -> Result<Body> {
|
||||
Err(not_built())
|
||||
}
|
||||
pub(crate) fn loft_to_point(_: &Plane, _: &[Piece], _: Vec3) -> Result<Body> {
|
||||
Err(not_built())
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "smlib"))]
|
||||
pub(crate) use stubs::{extrude, loft, loft_rails, revolve, screw, sweep, sweep_controlled};
|
||||
pub(crate) use stubs::{extrude, loft, loft_rails, loft_to_point, revolve, screw, sweep, sweep_controlled};
|
||||
|
||||
/// Refuses what no SMLib sweep does, before any work.
|
||||
pub(crate) fn refuse(what: &str) -> KernelError {
|
||||
@@ -315,14 +321,46 @@ mod imp {
|
||||
let n = plane.x.cross(plane.y).normalized().ok_or_else(|| KernelError::Invalid("profile plane".into()))?;
|
||||
let dir = n * sign.signum();
|
||||
let c = curves(&base, pieces)?;
|
||||
let top = plane.offset(from + sign.signum() * distance);
|
||||
let edges = seeds(&base, pieces, |q| q);
|
||||
let foot = draft.map_or(0.0, |d| d.foot);
|
||||
if let Some(d) = draft.filter(|d| d.foot > 0.0) {
|
||||
// A straight foot, then the walls sloping from its top to the far end the taper gives
|
||||
// from the base, joined face to face where they meet.
|
||||
if !(foot < distance) {
|
||||
return Err(KernelError::Invalid(format!("taper foot = {foot}")));
|
||||
}
|
||||
let knee = plane.offset(from + sign.signum() * foot);
|
||||
let lean = -(distance * d.taper.tan() / (distance - foot)).atan();
|
||||
let prism = Brep::extrude(&c, p(dir), foot, 0.0, true).map_err(|e| failed("extrude", e))?;
|
||||
let slope = Brep::extrude(&curves(&knee, pieces)?, p(dir), distance - foot, lean, true).map_err(|e| failed("extrude", e))?;
|
||||
let brep = prism.boolean(&slope, solidcraft_smlib::BooleanOp::Union).map_err(|e| failed("extrude", e))?;
|
||||
let brep = brep.ok_or_else(|| failed("extrude", "nothing is left"))?;
|
||||
let body = body_of(&brep, "extrude")?;
|
||||
// The caps and the foot's walls from the base; the sloping walls (and the foot's again)
|
||||
// from the edges at the knee, which both stand on.
|
||||
let low = roles(&body, &base, Some(&top), &edges, &[], false)?;
|
||||
let high = roles(&body, &knee, Some(&top), &seeds(&knee, pieces, |q| q), &[], false)?;
|
||||
let roles: Vec<Role> = low
|
||||
.into_iter()
|
||||
.zip(high)
|
||||
.map(|(a, b)| match (a, b) {
|
||||
(Role::Wall(mut x), Role::Wall(y)) => {
|
||||
x.extend(y);
|
||||
Role::Wall(x)
|
||||
}
|
||||
(Role::Unknown, w @ Role::Wall(_)) => w,
|
||||
(a, _) => a,
|
||||
})
|
||||
.collect();
|
||||
return with_history(body, &roles, pieces);
|
||||
}
|
||||
// SMLib's draft is positive inward; the kernel's taper grows the profile.
|
||||
let lean = draft.map_or(0.0, |d| -d.taper);
|
||||
let brep = Brep::extrude(&c, p(dir), distance, lean, true).map_err(|e| failed("extrude", e))?;
|
||||
let body = body_of(&brep, "extrude")?;
|
||||
let edges = seeds(&base, pieces, |q| q);
|
||||
// Half-way along a straight wall lies over its segment; a drafted one has moved.
|
||||
let inside = if draft.is_none() { seeds(&base, pieces, |q| q + dir * (distance * 0.5)) } else { Vec::new() };
|
||||
let top = plane.offset(from + sign.signum() * distance);
|
||||
let roles = roles(&body, &base, Some(&top), &edges, &inside, false)?;
|
||||
let sources = (draft.is_none() && sign > 0.0).then(|| extrude_sources(&roles, pieces, region, plane, &base, &top));
|
||||
let body = body.with_face_sources(sources.map(std::sync::Arc::new));
|
||||
@@ -419,18 +457,240 @@ mod imp {
|
||||
let reference = r.normalized().ok_or_else(|| KernelError::Invalid("path arc radius".into()))?;
|
||||
Curve::Arc { center: p(center), axis: p(k), reference: p(reference), radius, start: 0.0, end: turn }
|
||||
}
|
||||
crate::PathSeg::Curve { plane, seg } => curve(&plane, &seg)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The profile `pieces` on `plane` swept along `path`, turning with it, or only moving along
|
||||
/// it when `translate`.
|
||||
/// it when `translate`. SMLib's sweep follows a smooth path; at a corner (the direction
|
||||
/// jumping) the path is swept piece by piece ([`cornered`]).
|
||||
pub(crate) fn sweep(plane: &Plane, pieces: &[Piece], path: &[crate::PathSeg], translate: bool) -> Result<Body> {
|
||||
let runs = smooth_runs(path)?;
|
||||
if runs.len() > 1 {
|
||||
return cornered(plane, pieces, path, &runs, translate);
|
||||
}
|
||||
let brep = swept(plane, pieces, path, translate)?;
|
||||
let body = body_of(&brep, "sweep")?;
|
||||
let roles = roles(&body, plane, None, &seeds(plane, pieces, |q| q), &[], true)?;
|
||||
with_history(body, &roles, pieces)
|
||||
}
|
||||
|
||||
fn swept(plane: &Plane, pieces: &[Piece], path: &[crate::PathSeg], translate: bool) -> Result<Brep> {
|
||||
let c = curves(plane, pieces)?;
|
||||
let route = path.iter().map(path_curve).collect::<Result<Vec<_>>>()?;
|
||||
let frame = if translate { SweepFrame::Translate } else { SweepFrame::Follow };
|
||||
let brep = Brep::sweep(&c, &route, frame, true).map_err(|e| failed("sweep", e))?;
|
||||
Brep::sweep(&c, &route, frame, true).map_err(|e| failed("sweep", e))
|
||||
}
|
||||
|
||||
/// The path's segments in runs along which its direction carries on (within a thousandth of
|
||||
/// a radian across each joint): a new run starts at each corner.
|
||||
fn smooth_runs(path: &[crate::PathSeg]) -> Result<Vec<std::ops::Range<usize>>> {
|
||||
let ends: Vec<(Vec3, Vec3)> = path
|
||||
.iter()
|
||||
.map(crate::PathSeg::tangents)
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let mut runs = Vec::new();
|
||||
let mut from = 0;
|
||||
for (i, w) in ends.windows(2).enumerate() {
|
||||
if w[0].1.dot(w[1].0) < (1e-3f64).cos() {
|
||||
runs.push(from..i + 1);
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
runs.push(from..path.len());
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
/// `pl` carried rigidly by a turn of `angle` about the unit `k` through `center`.
|
||||
fn turned_plane(pl: &Plane, center: Vec3, k: Vec3, angle: f64) -> Plane {
|
||||
Plane { origin: center + turned(pl.origin - center, k, angle), x: turned(pl.x, k, angle), y: turned(pl.y, k, angle) }
|
||||
}
|
||||
|
||||
fn moved_plane(pl: &Plane, by: Vec3) -> Plane {
|
||||
Plane { origin: pl.origin + by, ..*pl }
|
||||
}
|
||||
|
||||
/// The profile's plane carried along `segs` as the sweep carries it: along a line it moves,
|
||||
/// round an arc it turns about the arc's axis; only moving with `translate`.
|
||||
fn carried(pl: &Plane, segs: &[crate::PathSeg], translate: bool) -> Result<Plane> {
|
||||
let mut pl = *pl;
|
||||
for s in segs {
|
||||
pl = match *s {
|
||||
crate::PathSeg::Line { a, b } => moved_plane(&pl, b - a),
|
||||
crate::PathSeg::Arc { a, center, axis, angle } => {
|
||||
let k = axis.normalized().ok_or_else(|| KernelError::Invalid("path arc axis".into()))?;
|
||||
if translate {
|
||||
moved_plane(&pl, crate::build::rotate_about(a, center, k, angle) - a)
|
||||
} else {
|
||||
turned_plane(&pl, center, k, angle)
|
||||
}
|
||||
}
|
||||
// A planar curve turns the profile in its plane as its direction turns.
|
||||
crate::PathSeg::Curve { plane, .. } => {
|
||||
let (a, b) = seg_ends(s)?;
|
||||
let moved = moved_plane(&pl, b - a);
|
||||
let (t0, t1) = s.tangents().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let n = plane.normal();
|
||||
if translate { moved } else { turned_plane(&moved, b, n, t0.cross(t1).dot(n).atan2(t0.dot(t1))) }
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(pl)
|
||||
}
|
||||
|
||||
/// Where `s` starts and ends.
|
||||
fn seg_ends(s: &crate::PathSeg) -> Result<(Vec3, Vec3)> {
|
||||
Ok(match *s {
|
||||
crate::PathSeg::Line { a, b } => (a, b),
|
||||
crate::PathSeg::Arc { a, center, axis, angle } => {
|
||||
let k = axis.normalized().ok_or_else(|| KernelError::Invalid("path arc axis".into()))?;
|
||||
(a, crate::build::rotate_about(a, center, k, angle))
|
||||
}
|
||||
crate::PathSeg::Curve { plane, seg } => (plane.to_world(seg.start()), plane.to_world(seg.end())),
|
||||
})
|
||||
}
|
||||
|
||||
/// A box on one side of the plane through `at` square to the unit `normal`: a `size` square
|
||||
/// in the plane, `size` deep along `normal`.
|
||||
fn half_space(at: Vec3, normal: Vec3, size: f64) -> Result<Brep> {
|
||||
let u = if normal.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
|
||||
let u = normal.cross(u).normalized().ok_or_else(|| KernelError::Invalid("mitre plane".into()))?;
|
||||
let v = normal.cross(u);
|
||||
let h = size * 0.5;
|
||||
let c = [at + (u + v) * h, at + (v - u) * h, at - (u + v) * h, at + (u - v) * h];
|
||||
let square: Vec<Curve> = (0..4).map(|i| Curve::Line { start: p(c[i]), end: p(c[(i + 1) % 4]) }).collect();
|
||||
Brep::extrude(&square, p(normal), size, 0.0, true).map_err(|e| failed("mitre", e))
|
||||
}
|
||||
|
||||
fn combined(a: &Brep, b: &Brep, op: solidcraft_smlib::BooleanOp, what: &str) -> Result<Brep> {
|
||||
a.boolean(b, op).map_err(|e| failed(what, e))?.ok_or_else(|| failed(what, "nothing is left"))
|
||||
}
|
||||
|
||||
/// A sweep along a path with corners: each smooth run of it swept on its own, and the runs
|
||||
/// joined at each corner. Turning with the path, the profile turns at a corner about the
|
||||
/// line square to both directions, and the runs meet in a mitre: each run is swept on
|
||||
/// straight past the corner (along its direction there) and cut back to the plane that halves
|
||||
/// the corner, where the next run, cut back the same way, meets it face to face. Only moving
|
||||
/// along the path, the profile is the same at the corner in both runs, which meet there.
|
||||
///
|
||||
/// History: the start cap is the face in the profile's plane, the end cap the face in its
|
||||
/// plane at the path's end; each wall comes from the profile segment it is swept from, found
|
||||
/// on the first run's start or half-way along each run.
|
||||
fn cornered(plane: &Plane, pieces: &[Piece], path: &[crate::PathSeg], runs: &[std::ops::Range<usize>], translate: bool) -> Result<Body> {
|
||||
let start = path.first().map(seg_ends).transpose()?.map(|(a, _)| a).ok_or_else(|| KernelError::Invalid("empty path".into()))?;
|
||||
// How far the profile reaches from the path.
|
||||
let reach = pieces
|
||||
.iter()
|
||||
.flat_map(|x| (0..=8).map(move |i| x.seg.point_at(f64::from(i) / 8.0)))
|
||||
.map(|q| plane.to_world(q).dist(start))
|
||||
.fold(0.0f64, f64::max);
|
||||
let mut length = 0.0;
|
||||
for s in path {
|
||||
let (a, b) = seg_ends(s)?;
|
||||
length += a.dist(b).max(match *s {
|
||||
crate::PathSeg::Arc { a, center, angle, .. } => (a - center).len() * angle.abs(),
|
||||
crate::PathSeg::Curve { seg, .. } => seg.length(),
|
||||
crate::PathSeg::Line { .. } => 0.0,
|
||||
});
|
||||
}
|
||||
let size = 4.0 * (length + reach) + 1.0;
|
||||
let tangent = |s: Option<&crate::PathSeg>| {
|
||||
s.and_then(crate::PathSeg::tangents).ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))
|
||||
};
|
||||
let (first_t, _) = tangent(path.first())?;
|
||||
let (_, last_t) = tangent(path.last())?;
|
||||
let end = path.last().map(seg_ends).transpose()?.map(|(_, b)| b).ok_or_else(|| KernelError::Invalid("empty path".into()))?;
|
||||
// A closed path ends where it starts, with no caps; at a corner there the last run and the
|
||||
// first meet in a mitre too.
|
||||
let closed = end.dist(start) <= 1e-6;
|
||||
let closing = (closed && last_t.dot(first_t) < (1e-3f64).cos()).then_some(first_t);
|
||||
let mitre = |t1: Vec3, t2: Vec3| -> Result<(Vec3, f64)> {
|
||||
let turn = t1.dot(t2).clamp(-1.0, 1.0).acos();
|
||||
if turn > std::f64::consts::PI - 1e-3 {
|
||||
return Err(KernelError::Invalid("the path turns back on itself at a corner".into()));
|
||||
}
|
||||
Ok(((t1 + t2).normalized().ok_or_else(|| KernelError::Invalid("path corner".into()))?, turn))
|
||||
};
|
||||
let mut pose = *plane;
|
||||
let mut body: Option<Brep> = None;
|
||||
let mut inside = Vec::new();
|
||||
// The mitre the next run starts from: its point, its normal (along the run) and the turn.
|
||||
let mut entry: Option<(Vec3, Vec3, f64)> = match closing {
|
||||
Some(_) if !translate => {
|
||||
let (m, turn) = mitre(last_t, first_t)?;
|
||||
Some((start, m, turn))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
for (ri, run) in runs.iter().enumerate() {
|
||||
let segs = path.get(run.clone()).ok_or_else(|| KernelError::Invalid("path run".into()))?;
|
||||
let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return Err(KernelError::Invalid("path run".into())) };
|
||||
let (t0, _) = first.tangents().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let (_, t1) = last.tangents().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let (a0, _) = seg_ends(first)?;
|
||||
let (_, b1) = seg_ends(last)?;
|
||||
// Half-way along the run (the middle of its middle segment), each wall over its piece.
|
||||
let mid = run.start + segs.len() / 2;
|
||||
let before = path.get(run.start..mid).ok_or_else(|| KernelError::Invalid("path run".into()))?;
|
||||
let at_mid = carried(&pose, before, translate)?;
|
||||
if let Some(m) = path.get(mid) {
|
||||
let half = match *m {
|
||||
crate::PathSeg::Line { a, b } => crate::PathSeg::Line { a, b: a + (b - a) * 0.5 },
|
||||
crate::PathSeg::Arc { a, center, axis, angle } => crate::PathSeg::Arc { a, center, axis, angle: angle * 0.5 },
|
||||
crate::PathSeg::Curve { plane, seg } => crate::PathSeg::Curve { plane, seg: seg.split_half().0 },
|
||||
};
|
||||
let there = carried(&at_mid, &[half], translate)?;
|
||||
inside.extend(seeds(&there, pieces, |q| q));
|
||||
}
|
||||
let next = match runs.get(ri + 1).and_then(|r| path.get(r.start)) {
|
||||
Some(s) => Some(tangent(Some(s))?.0),
|
||||
None => closing,
|
||||
};
|
||||
// The plane halving the corner at the run's end, and the turn there.
|
||||
let exit = next.map(|t2| mitre(t1, t2)).transpose()?;
|
||||
let piece = if translate {
|
||||
swept(&pose, pieces, segs, true)?
|
||||
} else {
|
||||
// On past each mitre: the profile's reach times the tangent of half the turn takes
|
||||
// every point of it to the mitre's plane; a little further, so the cut back runs
|
||||
// through the walls, never along the run's own end.
|
||||
let over = |turn: f64| reach * (turn * 0.5).tan() * 1.1 + reach * 1e-3 + 1e-6;
|
||||
let pre = entry.map_or(0.0, |(_, _, turn)| over(turn));
|
||||
let post = exit.map(|(_, turn)| over(turn)).unwrap_or(0.0);
|
||||
let mut route = Vec::with_capacity(segs.len() + 2);
|
||||
if pre > 0.0 {
|
||||
route.push(crate::PathSeg::Line { a: a0 - t0 * pre, b: a0 });
|
||||
}
|
||||
route.extend_from_slice(segs);
|
||||
if post > 0.0 {
|
||||
route.push(crate::PathSeg::Line { a: b1, b: b1 + t1 * post });
|
||||
}
|
||||
let mut piece = swept(&moved_plane(&pose, t0 * -pre), pieces, &route, false)?;
|
||||
if let Some((at, toward, _)) = entry {
|
||||
piece = combined(&piece, &half_space(at, -toward, size)?, solidcraft_smlib::BooleanOp::Subtract, "sweep mitre")?;
|
||||
}
|
||||
if let Some((m, _)) = exit {
|
||||
piece = combined(&piece, &half_space(b1, m, size)?, solidcraft_smlib::BooleanOp::Subtract, "sweep mitre")?;
|
||||
}
|
||||
piece
|
||||
};
|
||||
body = Some(match body {
|
||||
None => piece,
|
||||
Some(b) => combined(&b, &piece, solidcraft_smlib::BooleanOp::Union, "sweep corner")?,
|
||||
});
|
||||
// The profile at the run's end, then turned through the corner onto the next run.
|
||||
pose = carried(&pose, segs, translate)?;
|
||||
entry = None;
|
||||
if let (Some(t2), Some((m, turn)), false) = (next, exit, translate) {
|
||||
let k = t1.cross(t2).normalized().ok_or_else(|| KernelError::Invalid("path corner".into()))?;
|
||||
pose = turned_plane(&pose, b1, k, turn);
|
||||
entry = Some((b1, m, turn));
|
||||
}
|
||||
}
|
||||
let brep = body.ok_or_else(|| KernelError::Invalid("empty path".into()))?;
|
||||
let body = body_of(&brep, "sweep")?;
|
||||
let roles = roles(&body, plane, None, &seeds(plane, pieces, |q| q), &[], true)?;
|
||||
let roles = roles(&body, plane, (!closed).then_some(&pose), &seeds(plane, pieces, |q| q), &inside, false)?;
|
||||
with_history(body, &roles, pieces)
|
||||
}
|
||||
|
||||
@@ -439,6 +699,7 @@ mod imp {
|
||||
match *s {
|
||||
crate::PathSeg::Line { a, b } => a.dist(b),
|
||||
crate::PathSeg::Arc { a, center, angle, .. } => angle.abs() * a.dist(center),
|
||||
crate::PathSeg::Curve { seg, .. } => seg.length(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,6 +712,21 @@ mod imp {
|
||||
let q = crate::build::rotate_about(a, center, k, angle * f);
|
||||
Some((q, (k.cross(q - center) * angle.signum()).normalized()?))
|
||||
}
|
||||
// The parameter whose length from the start is that share of the curve's.
|
||||
crate::PathSeg::Curve { plane, seg } => {
|
||||
let want = seg.length() * f;
|
||||
let (mut lo, mut hi) = (0.0f64, 1.0f64);
|
||||
for _ in 0..48 {
|
||||
let t = 0.5 * (lo + hi);
|
||||
if seg.split_at(t).0.length() < want {
|
||||
lo = t;
|
||||
} else {
|
||||
hi = t;
|
||||
}
|
||||
}
|
||||
let t = 0.5 * (lo + hi);
|
||||
Some((plane.to_world(seg.point_at(t)), plane.dir_to_world(seg.derivative(t)).normalized()?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,6 +756,21 @@ mod imp {
|
||||
Ok((pts, tangents))
|
||||
}
|
||||
|
||||
/// How far the direction of travel turns along a path segment (radians, all told).
|
||||
fn path_turn(s: &crate::PathSeg) -> f64 {
|
||||
match *s {
|
||||
crate::PathSeg::Line { .. } => 0.0,
|
||||
crate::PathSeg::Arc { angle, .. } => angle.abs(),
|
||||
crate::PathSeg::Curve { seg, .. } => {
|
||||
let pts = seg.polyline(1e-3);
|
||||
pts.windows(3)
|
||||
.filter_map(|w| Some(((w[1] - w[0]).normalized()?, (w[2] - w[1]).normalized()?)))
|
||||
.map(|(u, v)| u.cross(v).atan2(u.dot(v)).abs())
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fewest and most stations of a sweep that twists, scales or follows rails.
|
||||
const MIN_STATIONS: usize = 16;
|
||||
const MAX_STATIONS: usize = 2048;
|
||||
@@ -492,16 +783,13 @@ mod imp {
|
||||
pub(crate) fn sweep_controlled(plane: &Plane, region: &NumberedRegion, path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
|
||||
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())),
|
||||
};
|
||||
let start = path.first().map(crate::PathSeg::start).ok_or_else(|| 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::<f64>() + ctl.twist.abs();
|
||||
let turn: f64 = path.iter().map(path_turn).sum::<f64>() + 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);
|
||||
@@ -853,6 +1141,16 @@ mod imp {
|
||||
loft_through(sections, first, &all, 3)
|
||||
}
|
||||
|
||||
/// A loft from the section `pieces` (lines) on `plane` to `apex`: the base is the start cap,
|
||||
/// each triangle comes from the segment it stands on.
|
||||
pub(crate) fn loft_to_point(plane: &Plane, pieces: &[Piece], apex: Vec3) -> Result<Body> {
|
||||
let c = curves(plane, pieces)?;
|
||||
let brep = Brep::loft_to_point(&c, p(apex)).map_err(|e| failed("loft to a point", e))?;
|
||||
let body = body_of(&brep, "loft to a point")?;
|
||||
let roles = roles(&body, plane, None, &seeds(plane, pieces, |q| q), &[], false)?;
|
||||
with_history(body, &roles, pieces)
|
||||
}
|
||||
|
||||
/// 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<Body> {
|
||||
|
||||
@@ -61,7 +61,7 @@ fn an_extrusion_names_its_caps_and_walls_and_keeps_their_sources() {
|
||||
// A drafted disc: a frustum, its wall the circle's.
|
||||
let disc = region(Loop2::circle(Vec2::ZERO, 5.0));
|
||||
let taper = -5f64.to_radians();
|
||||
let b = smlib_sweeps::extrude(&Plane::XY, &pieces_of(&disc), Some(&disc), 0.0, 4.0, 1.0, Some(Draft { taper })).unwrap();
|
||||
let b = smlib_sweeps::extrude(&Plane::XY, &pieces_of(&disc), Some(&disc), 0.0, 4.0, 1.0, Some(Draft { taper, foot: 0.0 })).unwrap();
|
||||
let r2 = 5.0 + 4.0 * taper.tan();
|
||||
close(volume(&b), PI * 4.0 / 3.0 * (25.0 + r2 * r2 + 5.0 * r2), 1e-3, "drafted disc");
|
||||
assert!(origins(&b).iter().filter(|o| o.contains("Profile")).count() >= 1);
|
||||
@@ -146,6 +146,95 @@ fn a_sweep_twists_scales_and_follows_rails_through_stations() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An 8 x 5 rectangle on the xy plane, centred on the path's start.
|
||||
fn bar_section() -> Region2 {
|
||||
region(Loop2::polygon(&[Vec2::new(-4.0, -2.5), Vec2::new(4.0, -2.5), Vec2::new(4.0, 2.5), Vec2::new(-4.0, 2.5)]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sweep_round_a_corner_meets_in_a_mitre_and_names_every_wall() {
|
||||
// 30 up then 40 across: mitred, the outer side gains what the inner loses, V = A (l1 + l2).
|
||||
let path = [
|
||||
crate::PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 30.0) },
|
||||
crate::PathSeg::Line { a: Vec3::new(0.0, 0.0, 30.0), b: Vec3::new(40.0, 0.0, 30.0) },
|
||||
];
|
||||
let b = smlib_sweeps::sweep(&Plane::XY, &pieces_of(&bar_section()), &path, false).unwrap();
|
||||
close(volume(&b), 2800.0, 1e-9, "mitred bar");
|
||||
// Each leg's four walls (the side walls in the bend's plane are one face round the corner,
|
||||
// flat as they are) from the four segments; the start and the end cap.
|
||||
let o = origins(&b);
|
||||
assert_eq!(o.iter().filter(|x| **x == cap(false)).count(), 1, "{o:?}");
|
||||
assert_eq!(o.iter().filter(|x| **x == cap(true)).count(), 1, "{o:?}");
|
||||
assert!(o.iter().all(|x| x.contains("Profile") || x.contains("Cap")), "{o:?}");
|
||||
for k in 0..4 {
|
||||
assert!(o.contains(&profile(k)), "segment {k}: {o:?}");
|
||||
}
|
||||
// Every wall of a straight leg is a plane.
|
||||
assert!((0..b.face_count()).all(|i| b.planar_face(i).is_some()), "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sweep_round_a_closed_path_has_no_caps() {
|
||||
// A square path of side 40 round the origin, the section in the plane square to its first
|
||||
// side at its middle: four mitred legs closing on themselves. Each leg's centre line is 40,
|
||||
// so V = A 4 40 (the mitres trade what they add and take).
|
||||
let c = [Vec3::new(20.0, -20.0, 0.0), Vec3::new(20.0, 20.0, 0.0), Vec3::new(-20.0, 20.0, 0.0), Vec3::new(-20.0, -20.0, 0.0)];
|
||||
let start = Vec3::new(20.0, 0.0, 0.0);
|
||||
let mut path = vec![crate::PathSeg::Line { a: start, b: c[1] }];
|
||||
for i in 1..4 {
|
||||
path.push(crate::PathSeg::Line { a: c[i], b: c[(i + 1) % 4] });
|
||||
}
|
||||
path.push(crate::PathSeg::Line { a: c[0], b: start });
|
||||
let plane = Plane { origin: start, x: Vec3::X, y: Vec3::Z };
|
||||
let sq = region(Loop2::polygon(&[Vec2::new(-2.0, -1.0), Vec2::new(2.0, -1.0), Vec2::new(2.0, 1.0), Vec2::new(-2.0, 1.0)]));
|
||||
let b = smlib_sweeps::sweep(&plane, &pieces_of(&sq), &path, false).unwrap();
|
||||
close(volume(&b), 8.0 * 160.0, 1e-9, "square frame");
|
||||
let o = origins(&b);
|
||||
assert!(o.iter().all(|x| x.contains("Profile")), "no caps round a closed path: {o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loft_through_holed_sections_keeps_the_hole_and_names_its_walls() {
|
||||
let ring = |z: f64| {
|
||||
let r = Region2 { outer: Loop2::circle(Vec2::ZERO, 20.0), holes: vec![Loop2::circle(Vec2::ZERO, 12.0)] };
|
||||
(Plane::XY.offset(z), r)
|
||||
};
|
||||
let b = crate::loft_regions(&[ring(0.0), ring(20.0)]).unwrap();
|
||||
close(volume(&b), PI * (400.0 - 144.0) * 20.0, 1e-3, "tube");
|
||||
let o = origins(&b);
|
||||
assert_eq!(b.face_count(), 4, "{o:?}");
|
||||
assert!(o.contains(&cap(false)) && o.contains(&cap(true)), "{o:?}");
|
||||
assert_eq!(o.iter().filter(|x| x.contains("Profile")).count(), 2, "outer wall and bore: {o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loft_to_a_point_is_a_pyramid() {
|
||||
let sq = Loop2::polygon(&[Vec2::new(-10.0, -10.0), Vec2::new(10.0, -10.0), Vec2::new(10.0, 10.0), Vec2::new(-10.0, 10.0)]);
|
||||
let b = crate::loft_to_point(&Plane::XY, &sq, Vec3::new(0.0, 0.0, 15.0)).unwrap();
|
||||
close(volume(&b), 2000.0, 1e-9, "pyramid");
|
||||
let mut want = vec![cap(false), profile(0), profile(1), profile(2), profile(3)];
|
||||
want.sort();
|
||||
assert_eq!(origins(&b), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drafted_extrusion_stands_on_a_straight_foot() {
|
||||
// A 10 x 10 square, 1 straight, then sloping out 5 degrees (taken from the base) up to 10:
|
||||
// the prism 100 and the frustum from 10 to 10 + 2 x 10 tan 5 over 9.
|
||||
let sq = region(Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]));
|
||||
let taper = 5f64.to_radians();
|
||||
let b = smlib_sweeps::extrude(&Plane::XY, &pieces_of(&sq), Some(&sq), 0.0, 10.0, 1.0, Some(Draft { taper, foot: 1.0 })).unwrap();
|
||||
let (a1, s2) = (100.0, 10.0 + 2.0 * 10.0 * taper.tan());
|
||||
let a2 = s2 * s2;
|
||||
close(volume(&b), 100.0 + 9.0 / 3.0 * (a1 + a2 + (a1 * a2).sqrt()), 1e-9, "footed draft");
|
||||
let o = origins(&b);
|
||||
assert!(o.contains(&cap(false)) && o.contains(&cap(true)), "{o:?}");
|
||||
// Each segment's foot and sloping wall.
|
||||
for k in 0..4 {
|
||||
assert_eq!(o.iter().filter(|x| **x == profile(k)).count(), 2, "segment {k}: {o:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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.
|
||||
@@ -164,3 +253,19 @@ fn a_twisted_sweep_round_a_closed_path_joins_its_ends() {
|
||||
let ctl = crate::SweepControl { twist: PI / 3.0, ..Default::default() };
|
||||
assert!(smlib_sweeps::sweep_controlled(&xz, &numbered(&square), &round, &ctl).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sweep_follows_a_free_form_path_as_a_curve() {
|
||||
// A 4 x 4 square along a planar S-shaped cubic Bézier (curvature radius above 20): V = A L.
|
||||
let xz = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
|
||||
let seg = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(0.0, 20.0), p2: Vec2::new(20.0, 20.0), p3: Vec2::new(20.0, 40.0) };
|
||||
let path = [crate::PathSeg::Curve { plane: xz, seg }];
|
||||
let sq = region(Loop2::polygon(&[Vec2::new(-2.0, -2.0), Vec2::new(2.0, -2.0), Vec2::new(2.0, 2.0), Vec2::new(-2.0, 2.0)]));
|
||||
let b = smlib_sweeps::sweep(&Plane::XY, &pieces_of(&sq), &path, false).unwrap();
|
||||
close(volume(&b), 16.0 * 46.220575543, 1e-3, "square along a Bézier");
|
||||
assert_eq!(b.face_count(), 6, "four smooth walls and two caps");
|
||||
// Twisted along it, through stations placed by length along the curve: the area is kept.
|
||||
let ctl = crate::SweepControl { twist: PI / 2.0, ..Default::default() };
|
||||
let b = smlib_sweeps::sweep_controlled(&Plane::XY, &numbered(&sq), &path, &ctl).unwrap();
|
||||
close(volume(&b), 16.0 * 46.220575543, 1e-3, "twisted square along a Bézier");
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg])
|
||||
if chains.is_empty() || chains.len() > MAX_CHAINS {
|
||||
return Err(KernelError::Invalid("pick the curves or profiles to sweep".into()));
|
||||
}
|
||||
let path = &PathSeg::flattened(path)[..];
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
}
|
||||
@@ -235,6 +236,8 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg])
|
||||
let part = guard("surface sweep", || Ok(mt::builder::rsweep(&cur, p3(center), crate::body::v3(k), mt::Rad(angle))))?;
|
||||
(part, Box::new(move |p| crate::build::rotate_about(p, center, k, angle)))
|
||||
}
|
||||
// The path was flattened: no curves are left.
|
||||
PathSeg::Curve { .. } => return Err(KernelError::Invalid("a free-form path curve".into())),
|
||||
};
|
||||
let next = far_wire(&part, &cur, map.as_ref())?;
|
||||
sides.extend(swept_sides(&part, &seeds, &[]));
|
||||
@@ -260,6 +263,7 @@ pub fn sweep_sheet_with(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::Path
|
||||
if ctl.is_default() {
|
||||
return sweep_sheet(plane, chains, path);
|
||||
}
|
||||
let path = &crate::PathSeg::flattened(path)[..];
|
||||
ctl.check()?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
|
||||
@@ -568,8 +568,7 @@ pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &Swe
|
||||
|
||||
/// [`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
|
||||
/// lines ([`crate::sweep_with`], which mitres the corners); 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<Body> {
|
||||
let pts = clean(path)?;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! SMLib's sweeps: extrusion (straight or drafted), revolution, sweep along a path, loft through
|
||||
//! sections or to a point, the screw sweep of a thread or coil, and the sweep of a profile placed
|
||||
//! at stations (one that twists, scales or follows guide rails along its path). Profiles, paths and
|
||||
//! sections are lists of
|
||||
//! [`Curve`]s; each sweep returns a new body.
|
||||
//! sections are lists of [`Curve`]s; each sweep returns a new body.
|
||||
//!
|
||||
//! With `cap`, the profile's loops close the ends and the result must be a closed manifold solid
|
||||
//! ([`Error::NotSolid`] otherwise); without, it is the swept sheet.
|
||||
|
||||
Reference in New Issue
Block a user