diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 69e86209a1..f1110275d3 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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::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![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, 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() }, } } diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index e4e6a18d70..993ae1bc9c 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -378,10 +378,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, 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)? diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 001e8d0a40..e4c50590ae 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -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 { - 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 { if holes == 0 { return loft(§ions.iter().map(|(p, r)| (*p, r.outer.clone())).collect::>()); } - if crate::backend()? == crate::Backend::Smlib { - return Err(crate::smlib_sweeps::refuse("loft through sections with holes")); - } let regions: Vec = 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 { if counts.iter().sum::() > 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 { }) } +/// [`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 { + let mut sections: Vec<(Plane, Vec)> = + 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::>>()?; + 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 { + 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 { 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 { 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::>().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> { - 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 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 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() diff --git a/crates/kernel/src/helix.rs b/crates/kernel/src/helix.rs index d4169ebf47..482b0294c5 100644 --- a/crates/kernel/src/helix.rs +++ b/crates/kernel/src/helix.rs @@ -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 { - 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 { // 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 = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect(); diff --git a/crates/kernel/src/smlib_sweeps.rs b/crates/kernel/src/smlib_sweeps.rs index 9e19b3202c..08d525409a 100644 --- a/crates/kernel/src/smlib_sweeps.rs +++ b/crates/kernel/src/smlib_sweeps.rs @@ -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)], _: usize, _: &[Vec], _: crate::RailFit) -> Result { Err(not_built()) } + pub(crate) fn loft_to_point(_: &Plane, _: &[Piece], _: Vec3) -> Result { + 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 = 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 { + 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 { let c = curves(plane, pieces)?; let route = path.iter().map(path_curve).collect::>>()?; 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>> { + let ends: Vec<(Vec3, Vec3)> = path + .iter() + .map(crate::PathSeg::tangents) + .collect::>() + .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 { + 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 { + 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 = (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 { + 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], translate: bool) -> Result { + 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 = 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 { 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::() + ctl.twist.abs(); + let turn: f64 = path.iter().map(path_turn).sum::() + 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 { + 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 { diff --git a/crates/kernel/src/smlib_sweeps_tests.rs b/crates/kernel/src/smlib_sweeps_tests.rs index f0489bb023..aed894ae1a 100644 --- a/crates/kernel/src/smlib_sweeps_tests.rs +++ b/crates/kernel/src/smlib_sweeps_tests.rs @@ -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"); +} diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 51da727815..af6dee9d58 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -210,6 +210,7 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec], 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], 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], 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())); diff --git a/crates/kernel/src/sweep_path.rs b/crates/kernel/src/sweep_path.rs index 53490c4af1..8b0ec78e28 100644 --- a/crates/kernel/src/sweep_path.rs +++ b/crates/kernel/src/sweep_path.rs @@ -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 { let pts = clean(path)?; diff --git a/crates/smlib/src/sweeps.rs b/crates/smlib/src/sweeps.rs index 18e17adaa0..a133bb31ed 100644 --- a/crates/smlib/src/sweeps.rs +++ b/crates/smlib/src/sweeps.rs @@ -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.