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:
jyviko
2026-10-21 22:55:56 -04:00
parent faae48d6ee
commit 69a7a85cb2
9 changed files with 524 additions and 62 deletions
+16 -17
View File
@@ -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() },
}
}
+1 -4
View File
@@ -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
View File
@@ -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(&sections.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(&regions);
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(&sections, 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()
+3 -3
View File
@@ -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();
+310 -12
View File
@@ -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> {
+106 -1
View File
@@ -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");
}
+4
View File
@@ -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()));
+1 -2
View File
@@ -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 -2
View File
@@ -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.