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