diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs
index be65121947..7b65f1d863 100644
--- a/crates/kernel/src/build.rs
+++ b/crates/kernel/src/build.rs
@@ -799,24 +799,61 @@ pub fn extrude_tapered_between(
smlib_tapered(plane, region, from, to, foot, dir_sign.signum(), taper, corner)
}
-/// [`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, after any straight foot.
+/// [`extrude_tapered_between`] through SMLib's draft extrusion, its section at `from` the
+/// profile offset by the taper, after any straight foot. SMLib's draft extrusion takes one loop:
+/// a region with holes is its outer loop drafted, less each hole drafted the other way (every
+/// wall leaning by the taper on the material's side), refused only where the taper would make a
+/// hole meet the outer wall or another hole within the extrusion.
#[allow(clippy::too_many_arguments)]
fn smlib_tapered(plane: &Plane, region: &Region2, from: f64, to: f64, foot: f64, sign: f64, taper: f64, corner: DraftCorner) -> Result
{
- if !region.holes.is_empty() {
- return Err(crate::smlib_sweeps::refuse("draft extrusion of a profile with holes (SMLib's takes one loop)"));
- }
- let outer = oriented(region)?.map_loops(|l| l.merged_lines(continues_straight)).outer;
- let bottom = if from == 0.0 || taper == 0.0 {
- outer
- } else {
- let moved = offset_loop(&outer.lp, from * taper.tan())?;
- let segs: Option> = moved.into_iter().collect();
+ let joined = oriented(region)?.map_loops(|l| l.merged_lines(continues_straight));
+ // A loop's section `at` along the extrusion: counter-clockwise, grown by `taper` (a hole's
+ // the other way, as the wall of the material round it leans).
+ let section = |l: &NumberedLoop, at: f64, taper: f64| -> Result {
+ let ccw = l.oriented(false);
+ if at == 0.0 || taper == 0.0 {
+ return Ok(ccw);
+ }
+ let segs: Option> = offset_loop(&ccw.lp, at * taper.tan())?.into_iter().collect();
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 }
+ Ok(NumberedLoop { lp: Loop2 { segs }, from: ccw.from })
};
+ let outer = section(&joined.outer, from, taper)?;
let draft = (taper != 0.0).then_some(crate::smlib_sweeps::Draft { taper, foot, corner });
- crate::smlib_sweeps::extrude(plane, &crate::smlib_sweeps::pieces([&bottom]), Some(region), from * sign, to - from, sign, draft)
+ if joined.holes.is_empty() {
+ return crate::smlib_sweeps::extrude(plane, &crate::smlib_sweeps::pieces([&outer]), Some(region), from * sign, to - from, sign, draft);
+ }
+ let Some(draft) = draft else {
+ return Err(KernelError::Invalid("a tapered extrusion needs a taper".into()));
+ };
+ let holes: Vec = joined.holes.iter().map(|h| section(h, from, -taper)).collect::>()?;
+ // The sections where the walls are farthest moved, the two ends: each hole stays inside the
+ // outer loop and clear of the other holes there (the walls are planes, cones and their like,
+ // which move monotonically in between).
+ for at in [from, to] {
+ let o = section(&joined.outer, at, taper)?;
+ let hs: Vec = joined.holes.iter().map(|h| section(h, at, -taper)).collect::>()?;
+ let pts = |l: &NumberedLoop| l.lp.segs.iter().flat_map(|s| s.polyline(1e-3)).collect::>();
+ // Apart: no point of either inside the other.
+ let apart = |a: &NumberedLoop, b: &NumberedLoop| !pts(a).iter().any(|p| b.lp.contains(*p)) && !pts(b).iter().any(|p| a.lp.contains(*p));
+ for (i, h) in hs.iter().enumerate() {
+ let inside = pts(h).iter().all(|p| o.lp.contains(*p)) && !pts(&o).iter().any(|p| h.lp.contains(*p));
+ if !inside || hs.iter().skip(i + 1).any(|k| !apart(h, k)) {
+ return Err(KernelError::Invalid("the draft makes a hole meet the outer wall or another hole".into()));
+ }
+ }
+ }
+ let all: Vec = std::iter::once(outer.clone()).chain(holes.iter().map(|h| h.oriented(true))).collect();
+ crate::smlib_sweeps::extrude_drafted_with_holes(
+ plane,
+ &crate::smlib_sweeps::pieces([&outer]),
+ &holes.iter().map(|h| crate::smlib_sweeps::pieces([h])).collect::>(),
+ &crate::smlib_sweeps::pieces(all.iter()),
+ from * sign,
+ to - from,
+ sign,
+ draft,
+ )
}
/// A planar region on `plane` as the schema 4 record of one face (an open sheet): the plane's
diff --git a/crates/kernel/src/smlib_sweeps.rs b/crates/kernel/src/smlib_sweeps.rs
index 62386961f3..008b981ac3 100644
--- a/crates/kernel/src/smlib_sweeps.rs
+++ b/crates/kernel/src/smlib_sweeps.rs
@@ -71,8 +71,8 @@ pub(crate) struct Draft {
}
pub(crate) use imp::{
- body_from_record, curve, extrude, face_section, loft, loft_rails, loft_to_point, path_curve, project_onto_face, revolve, screw, screw_radial,
- sphere, sweep, sweep_controlled,
+ body_from_record, curve, extrude, extrude_drafted_with_holes, face_section, loft, loft_rails, loft_to_point, path_curve, project_onto_face,
+ revolve, screw, screw_radial, sphere, sweep, sweep_controlled,
};
/// Refuses what no SMLib sweep does, before any work.
@@ -447,6 +447,79 @@ mod imp {
with_history(body, &roles, pieces)
}
+ /// The solid `pieces` (one closed loop) on `plane` sweep, moved `from` along its normal and
+ /// drafted `d` over `distance` along `sign` × the normal, as [`extrude`] builds it, without
+ /// its roles.
+ fn drafted_loop(plane: &Plane, pieces: &[Piece], from: f64, distance: f64, sign: f64, d: Draft) -> Result {
+ let base = plane.offset(from);
+ let n = plane.x.cross(plane.y).normalized().ok_or_else(|| KernelError::Invalid("profile plane".into()))?;
+ let dir = n * sign.signum();
+ if d.foot > 0.0 {
+ if !(d.foot < distance) {
+ return Err(KernelError::Invalid(format!("taper foot = {}", d.foot)));
+ }
+ let knee = plane.offset(from + sign.signum() * d.foot);
+ let lean = -(distance * d.taper.tan() / (distance - d.foot)).atan();
+ let prism =
+ Brep::extrude(&curves(&base, pieces)?, p(dir), d.foot, 0.0, DraftCorner::default(), true).map_err(|e| failed("extrude", e))?;
+ let slope =
+ Brep::extrude(&curves(&knee, pieces)?, p(dir), distance - d.foot, lean, corner(d.corner), true).map_err(|e| failed("extrude", e))?;
+ let brep = prism.boolean(&slope, solidcraft_smlib::BooleanOp::Union).map_err(|e| failed("extrude", e))?;
+ return brep.ok_or_else(|| failed("extrude", "nothing is left"));
+ }
+ Brep::extrude(&curves(&base, pieces)?, p(dir), distance, -d.taper, corner(d.corner), true).map_err(|e| failed("extrude", e))
+ }
+
+ /// A drafted extrusion of a region with holes: SMLib's draft extrusion takes one loop, so the
+ /// outer loop is drafted and each hole, turned counter-clockwise, is drafted the other way
+ /// and cut from it. Every wall leans by the taper on the material's side, as a single
+ /// drafted sweep would: a taper that narrows the outer boundary widens the holes. `outer` and
+ /// `holes` are the loops' pieces at `from` as [`extrude`] takes them, `all` the region's
+ /// pieces in their numbering (outer, then the holes as they bound the material), which the
+ /// walls' roles name.
+ pub(crate) fn extrude_drafted_with_holes(
+ plane: &Plane,
+ outer: &[Piece],
+ holes: &[Vec],
+ all: &[Piece],
+ from: f64,
+ distance: f64,
+ sign: f64,
+ draft: Draft,
+ ) -> Result {
+ let mut brep = drafted_loop(plane, outer, from, distance, sign, draft)?;
+ for h in holes {
+ let cutter = drafted_loop(plane, h, from, distance, sign, Draft { taper: -draft.taper, ..draft })?;
+ brep = brep
+ .boolean(&cutter, solidcraft_smlib::BooleanOp::Subtract)
+ .map_err(|e| failed("extrude", e))?
+ .ok_or_else(|| KernelError::Invalid("the draft leaves nothing of the profile".into()))?;
+ }
+ let body = body_of(&brep, "extrude")?;
+ let base = plane.offset(from);
+ let top = plane.offset(from + sign.signum() * distance);
+ let low = roles(&body, &base, Some(&top), &seeds(&base, all, |q| q), &[], false)?;
+ let roles: Vec = if draft.foot > 0.0 {
+ // The sloping walls stand on their edges at the knee, as in [`extrude`].
+ let knee = plane.offset(from + sign.signum() * draft.foot);
+ let high = roles(&body, &knee, Some(&top), &seeds(&knee, all, |q| q), &[], false)?;
+ 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()
+ } else {
+ low
+ };
+ with_history(body, &roles, all)
+ }
+
/// The construction sources of a straight extrusion's faces along the normal of `sketch`, as
/// [`crate::extrude`] gives them: each cap the region at its end (both caps found once
/// each), each wall swept from one piece that piece as drawn, a line's with the wall's plane.
diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs
index a632470d1f..21abd4d90b 100644
--- a/crates/kernel/src/tests.rs
+++ b/crates/kernel/src/tests.rs
@@ -463,6 +463,21 @@ fn tapered_extrudes() {
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
assert!(m.bbox.max.z < 1e-6 && m.bbox.min.z > -10.0 - 1e-6, "{:?}", m.bbox);
assert!(extrude_tapered(&Plane::XY, &c, 10.0, 1.0, -60f64.to_radians(), crate::DraftCorner::default()).is_err(), "closes the profile");
+ // The square with a 10 × 10 square hole, 30 up, 5° inward: every wall leans in toward the
+ // material's outside, so the outer frustum narrows and the hole's widens; the solid is the
+ // one frustum less the other, with each wall a face of its own (four out, four in, two caps).
+ let t = 5f64.to_radians().tan();
+ let hole = Loop2::polygon(&[Vec2::new(-5.0, -5.0), Vec2::new(-5.0, 5.0), Vec2::new(5.0, 5.0), Vec2::new(5.0, -5.0)]);
+ let ring = Region2 { outer: sq.outer.clone(), holes: vec![hole] };
+ let b = extrude_tapered(&Plane::XY, &ring, 30.0, 1.0, -5f64.to_radians(), crate::DraftCorner::default()).unwrap();
+ let frustum = |a0: f64, a1: f64| 30.0 / 3.0 * (a0 * a0 + a1 * a1 + a0 * a1);
+ let v = frustum(40.0, 40.0 - 60.0 * t) - frustum(10.0, 10.0 + 60.0 * t);
+ let m = measure(&b).unwrap();
+ assert!(rel(m.volume, v) < 1e-6, "{} vs {v}", m.volume);
+ assert_eq!(m.merged.faces, 10);
+ // At 30° the hole would grow past the outer wall: refused, saying why.
+ let e = extrude_tapered(&Plane::XY, &ring, 30.0, 1.0, -30f64.to_radians(), crate::DraftCorner::default()).unwrap_err();
+ assert!(e.to_string().contains("hole"), "{e}");
}
/// A slot (two lines and two half-circles, tangent) tapered inward: every edge moves in by