From 07708a4e2a18db97388486f8683ff7da255843c4 Mon Sep 17 00:00:00 2001 From: Brazz <42916786+acbrazzl@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:18:43 -0400 Subject: [PATCH] Extrude: a taper composes with a start offset (#2312) (#2313) Thanks @acbrazzl. --- crates/doc/src/eval.rs | 25 ++- crates/engine/src/cmd/features_more_tests.rs | 159 +++++++++++++++++++ docs/book/src/guide/features.md | 6 +- 3 files changed, 181 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 8c1cdf37..94c14115 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3195,21 +3195,30 @@ fn feature_tools_with_constructor( }; if let Some(tp) = &extent.taper { let taper = val(vals, tp, Kind::Angle)?; - if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric { - return Err(DocError::Invalid("not supported yet: taper with two-sided, symmetric or offset extents".into())); + if extent.distance2.is_some() || extent.direction == crate::Direction::Symmetric { + return Err(DocError::Invalid("not supported yet: taper with two-sided or symmetric extents".into())); } let sign = if extent.to.is_none() && extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 }; let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) }; - let start_plane = ss.plane.offset(from); + // Where the extrude starts: the From Object's level and a start offset along the + // normal from it. The offset moves the profile that far and the sides are drafted + // from there, so the profile stays the section at the start whatever the offset + // (`d` is measured from the start already, To Object included). + let base = from + + match &extent.start_offset { + Some(e) => val(vals, e, Kind::Length)?, + None => 0.0, + }; + let start_plane = ss.plane.offset(base); let caps = [ - Some(crate::lineage::extrude_cap_role(from)), - Some(crate::lineage::extrude_cap_role(if sign < 0.0 { from - len } else { from + len })), + Some(crate::lineage::extrude_cap_role(base)), + Some(crate::lineage::extrude_cap_role(if sign < 0.0 { base - len } else { base + len })), ]; // How far a tool grows past its profile at either end, at most. let mut growth: f64 = 0.0; // A face of the targets in the profile's plane (the same for every profile). let plain_targets: Vec = crate::plain(targets); - let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, &plain_targets); + let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, base, &plain_targets); let surroundings = Surroundings::of(st, *operation, &plain_targets); let tools: Vec = regions .iter() @@ -3233,9 +3242,9 @@ fn feature_tools_with_constructor( growth = growth.max(len * taper.tan().abs()); return Ok(roles.tool(exact, None)); } - let (lo, hi) = if sign < 0.0 { (from - len, from) } else { (from, from + len) }; + let (lo, hi) = if sign < 0.0 { (base - len, base) } else { (base, base + len) }; let (l2, h2) = surroundings.as_ref().map_or((lo, hi), |sur| extend_for_coplanar(sur, &ss.plane, r, lo, hi)); - let (mut a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) }; + let (mut a, b) = if sign < 0.0 { (base - h2, base - l2) } else { (l2 - base, h2 - base) }; // A taper that narrows the tool away from the face widens it as it runs on into it, past // the profile (where the profile is the whole face, out through the body's sides, #1534): // then it starts on the face instead. diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 94a6421e..ee30cf0c 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -952,6 +952,165 @@ fn a_tapered_foot_under_a_part_joins_as_drawn() { } } +/// A drafted extrude that starts above its sketch plane. The start offset moves the profile +/// along the sketch normal and the walls are drafted from there, so the section at the start +/// plane is the profile itself and the solid is the frustum over it, moved along the normal by +/// the offset. Controls that build today: the same extrude with the taper and no offset, and +/// with the offset and no taper. Two-sided and symmetric tapers are still refused, by name. +#[test] +fn a_drafted_extrude_starts_at_an_offset() { + use solidcraft_kernel::{FaceSurface, face_surface, measure as measure_body}; + // A 30 x 20 profile, 12 mm long, drafted 6 deg, started 4 mm along the normal. + let (w, d, h, off) = (30.0, 20.0, 12.0, 4.0); + let part = |p: Value| -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[w,d]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", p); + s + }; + // The straight prism, and the frustum over the profile: every edge moves out by z*tan a + // over the start, so A(z) = (w + 2 z t)(d + 2 z t) and + // V = integral of A over 0..h = w*d*h + (w + d)*t*h^2 + 4/3*t^2*h^3 (exact: A is quadratic). + let prism = w * d * h; + let frustum = |t: f64| w * d * h + (w + d) * t * h * h + 4.0 / 3.0 * t * t * h * h * h; + // The two caps plus four trapezoid walls, each (base + top)/2 by the slant height h/cos a. + let area_of = |a: f64| { + let t = a.tan(); + w * d + (w + 2.0 * h * t) * (d + 2.0 * h * t) + 2.0 * h / a.cos() * ((w + h * t) + (d + h * t)) + }; + + // Control: the taper alone, from the sketch plane. + let want = frustum(6f64.to_radians().tan()); + let mut s = part(json!({"distance":h,"taper":"6 deg","body_name":"B"})); + let v = volume(&mut s); + assert!(rel(v, want) < 1e-9, "taper alone: {v} mm3 for {want}"); + + // Control: the offset alone, no taper: the prism, moved along the normal. + let mut s = part(json!({"distance":h,"start_offset":off,"body_name":"B"})); + let v = volume(&mut s); + assert!(rel(v, prism) < 1e-9, "offset alone: {v} mm3 for {prism}"); + let st = s.model.state(); + let m = measure_body(&st.body("B").unwrap().body).unwrap(); + assert!((m.bbox.min.z - off).abs() < 1e-9 && (m.bbox.max.z - (off + h)).abs() < 1e-9, "offset alone: {:?}", m.bbox); + + // Both: the frustum over the profile, standing on the offset plane, either way along the + // normal and drafted either outward or inward. + for deg in [6.0f64, -6.0] { + for (dir, sign) in [("positive", 1.0f64), ("negative", -1.0)] { + let (ang, t) = (deg.to_radians(), deg.to_radians().tan()); + let (v_want, a_want) = (frustum(t), area_of(ang)); + let at = format!("{deg} deg {dir}"); + let mut s = part(json!({"distance":h,"direction":dir,"start_offset":off,"taper":format!("{deg} deg"),"body_name":"B"})); + let v = volume(&mut s); + assert!(rel(v, v_want) < 1e-9, "{at}: {v} mm3 for {v_want}"); + let st = s.model.state(); + let b = &st.body("B").unwrap().body; + let m = measure_body(b).unwrap(); + assert_eq!(st.bodies.len(), 1, "{at}: one body"); + assert_eq!(m.shells, 1, "{at}: one lump"); + assert_eq!(m.merged.faces, 6, "{at}: four walls and two caps"); + // The area pins both caps: the profile at the offset plane, the drafted section at + // the far end. (The volume alone cannot: it is measured on a tessellation.) + assert!(rel(m.area, a_want) < 1e-9, "{at}: {} mm2 for {a_want}", m.area); + // The solid lies between the offset plane and the far end, and the widening (or + // narrowing) is all on the far side of the profile. + let (lo, far) = if sign > 0.0 { (off, off + h) } else { (off - h, off) }; + assert!((m.bbox.min.z - lo).abs() < 1e-9 && (m.bbox.max.z - (lo + h)).abs() < 1e-9, "{at}: {:?}", m.bbox); + let (x0, x1) = (0f64.min(-h * t), w.max(w + h * t)); + let (y0, y1) = (0f64.min(-h * t), d.max(d + h * t)); + assert!((m.bbox.min.x - x0).abs() < 1e-9 && (m.bbox.max.x - x1).abs() < 1e-9, "{at}: {:?} for x {x0}..{x1}", m.bbox); + assert!((m.bbox.min.y - y0).abs() < 1e-9 && (m.bbox.max.y - y1).abs() < 1e-9, "{at}: {:?} for y {y0}..{y1}", m.bbox); + // Every face names an exact surface: four planes leaning by the taper and two flat + // caps, one of them the profile's plane at the offset. A tessellated stand-in + // measures nearly the same volume and names no surface at all. + let (mut walls, mut caps) = (0, 0); + for i in 0..b.face_count() { + match face_surface(b, i) { + Some(FaceSurface::Plane { normal, point }) if normal.z.abs() > 1.0 - 1e-9 => { + caps += 1; + assert!((point.z - lo).abs() < 1e-9 || (point.z - far).abs() < 1e-9, "{at}: a cap at z = {}", point.z); + } + Some(FaceSurface::Plane { normal, .. }) => { + walls += 1; + assert!((normal.z + sign * ang.sin()).abs() < 1e-9, "{at}: a wall leaning {}, not {}", normal.z, -sign * ang.sin()); + } + other => panic!("{at}: face {i} is {other:?}"), + } + } + assert_eq!((walls, caps), (4, 2), "{at}"); + } + } + + // The extents that are still to come are refused, and say which they are. + for p in [json!({"distance":h,"distance2":5,"taper":"6 deg"}), json!({"distance":h,"direction":"symmetric","taper":"6 deg"})] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[w,d]})); + run(&mut s, "sketch.finish", json!({})); + let e = s.execute("solid.extrude", &p).unwrap_err().to_string(); + assert!(e.contains("not supported yet: taper with two-sided or symmetric extents"), "{p}: {e}"); + } +} + +/// A drafted pocket that starts above its sketch plane, cut into a body three booleans deep: +/// its rim lies on a face bounded by an intersection curve, not by straight lines. The tool +/// lies wholly in material, so the cut removes exactly the frustum over its profile. +#[test] +fn a_drafted_offset_cut_into_a_body_with_history() { + use solidcraft_kernel::{FaceSurface, face_surface, measure as measure_body}; + let (w, d, h, off) = (30.0, 20.0, 12.0, 18.0); + let (ang, t) = (6f64.to_radians(), 6f64.to_radians().tan()); + let frustum = w * d * h + (w + d) * t * h * h + 4.0 / 3.0 * t * t * h * h * h; + let unnamed = |b: &solidcraft_kernel::Body| (0..b.face_count()).filter(|i| face_surface(b, *i).is_none()).count(); + + let mut s = Session::default(); + // A 60 x 40 x 30 block, a round boss joined through its far side and out of its top, and a + // round pocket cut in the top: the top face is bounded by the boss's intersection curve. + run(&mut s, "solid.box", json!({"length":60,"width":40,"height":30,"corner":[0,0,0],"body_name":"P"})); + run(&mut s, "solid.cylinder", json!({"radius":8,"height":46,"base":[58,20,-8],"operation":"join"})); + run(&mut s, "solid.cylinder", json!({"radius":4,"height":12,"base":[10,34,20],"operation":"cut"})); + let before = volume(&mut s); + { + let st = s.model.state(); + assert_eq!(st.bodies.len(), 1, "the block, the boss and the pocket are one body"); + assert_eq!(unnamed(&st.body("P").unwrap().body), 0, "faces without a surface before the cut"); + } + + // The pocket: drafted 6 deg outward, starting 18 mm up the normal and out through the top. + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[35,25]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":h,"start_offset":off,"taper":"6 deg","operation":"cut"})); + let after = volume(&mut s); + let removed = before - after; + assert!((removed - frustum).abs() < 1e-6 * frustum, "{removed} mm3 removed for the frustum's {frustum}"); + + let st = s.model.state(); + let b = &st.body("P").unwrap().body; + let m = measure_body(b).unwrap(); + assert_eq!(st.bodies.len(), 1, "one body"); + assert_eq!(m.shells, 1, "one lump: the pocket is open at the top"); + assert_eq!(unnamed(b), 0, "faces without a surface after the cut"); + // Four drafted walls, and the pocket's floor at the offset: the only leaning planes of the + // body (the block's own faces are level or upright), and the only face in the plane the cut + // starts on. A cut keeps the tool's sides facing into the pocket, so a wall drafted outward + // by a leans by +sin a, the other way round from the same extrude as a new body. + let (mut walls, mut floor) = (0, 0); + for i in 0..b.face_count() { + let Some(FaceSurface::Plane { normal, point }) = face_surface(b, i) else { continue }; + if (normal.z - ang.sin()).abs() < 1e-9 { + walls += 1; + } else if normal.z > 1.0 - 1e-9 && (point.z - off).abs() < 1e-9 { + floor += 1; + } else { + assert!(normal.z.abs() < 1e-9 || normal.z.abs() > 1.0 - 1e-9, "a plane leaning {}, neither the block's nor the pocket's", normal.z); + } + } + assert_eq!((walls, floor), (4, 1), "four drafted walls, and the pocket's floor at z = {off}"); +} + /// A rounded part as drawn in the app: a rectangle and a quarter circle tangent to its top /// line, extruded together. The arc (about (62.17, -21), radius 21) ends square at the front /// face and in a knife-edge tip where it runs into the back face. diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index 339409f3..40485f11 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -24,7 +24,11 @@ spline and conic sides the slope is approximate: the moved outline is a curve of within 2 % of the taper's offset (or 10 µm) of the true one, a curve that turns too much for that being cut into pieces first; where a curve bends tighter than the offset, the offset folds and is smoothed over. A taper steeper than the profile's details (a stroke, a curve or a gap -narrower than it) is refused with that reason; a smaller taper or distance builds. +narrower than it) is refused with that reason; a smaller taper or distance builds. A taper +composes with a start offset (a drafted pocket that starts part way up a part): the offset moves +the profile along the normal and the sides are drafted from there, so the profile is the section +at the start whatever the offset. A taper with a two-sided or symmetric extent is not built yet +and says so. **Thin Extrude** (`thin: true` on `solid.extrude`, the Thin Extrude box in its dialog) builds a wall of constant thickness along sketch curves instead of filling profiles: open chains (a single