From d0ff1dcfa49a1c77b2a9adfaae563f665b388b30 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:29:45 -0400 Subject: [PATCH] doc: an extrude's taper without a corner rule picks one per profile Extent.corner is optional: unset, each profile preserves curvature where every curve is a line or an arc and takes the tangent rule where another curve has a corner, with a note in the feature's warning; a rule the user gave is kept. draft_corners reports not_exact as the kernel builds (any free curve in a cornered profile). Builds (release CLI); cases not yet run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt --- crates/doc/src/document.rs | 15 ++++--- crates/doc/src/eval.rs | 40 ++++++++++++++++--- crates/doc/src/format/pending/draft-corner.rs | 13 +++--- crates/engine/src/cmd/features.rs | 6 +-- crates/kernel/src/draft_corners.rs | 7 +++- 5 files changed, 57 insertions(+), 24 deletions(-) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 93cbf404ed..46c70f3b50 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -274,10 +274,6 @@ impl ExtrudeConvention { } } -fn draft_corner_is_default(c: &solidcraft_kernel::DraftCorner) -> bool { - *c == solidcraft_kernel::DraftCorner::default() -} - /// Extent of an extrude. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)] pub struct Extent { @@ -305,10 +301,13 @@ pub struct Extent { #[serde(default, skip_serializing_if = "Option::is_none")] #[walk(angle = "Taper angle")] pub taper: Option, - /// How a taper closes the profile's sharp corners ([`solidcraft_kernel::draft_corners`] says - /// what each rule gives at each corner). - #[serde(default, skip_serializing_if = "draft_corner_is_default")] - pub corner: solidcraft_kernel::DraftCorner, + /// How a taper closes the profile's sharp corners, when the user chose + /// ([`solidcraft_kernel::draft_corners`] says what each rule gives at each corner). Without + /// it each profile takes preserve curvature where the kernel can build it exactly and the + /// tangent rule where a curve that is neither a line nor an arc has a corner, and the + /// feature's warning says so. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub corner: Option, /// To Object: end at the face (or vertex/point) at this point, instead of `distance`. #[serde(default, skip_serializing_if = "Option::is_none")] pub to: Option, diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 9d3f945cd3..8f01f37116 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2457,6 +2457,28 @@ pub fn draft_corner_options(ss: &SolvedSketch, sel: &ProfileSel, distance: f64, Ok(out) } +/// The corner rule a taper takes for profile `r` of `ss` when the user chose none: preserve +/// curvature, unless a corner of the profile cannot keep it exactly (a curve that is neither a +/// line nor an arc), then the tangent rule, with a note in `notes` saying where and why. +fn draft_corner_for(ss: &SolvedSketch, r: &Region2, distance: f64, taper: f64, notes: &mut Vec) -> Result { + let reports = kernel::draft_corners(r, distance, taper)?; + let inexact = reports.iter().find(|c| { + c.options.iter().any(|o| o.rule == kernel::DraftCorner::PreserveCurvature && o.fill == Err(kernel::CornerRefusal::NotExact)) + }); + Ok(match inexact { + Some(c) => { + notes.push(format!( + "the taper's corners of profile at ({}, {}) in {} use the tangent rule: a curve of it is neither a line nor an arc, so its walls cannot continue as themselves exactly; corner: round also builds", + show_mm(c.at.x), + show_mm(c.at.y), + ss.name + )); + kernel::DraftCorner::Tangent + } + None => kernel::DraftCorner::PreserveCurvature, + }) +} + pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result> { let ps = &ss.profiles; if ps.is_empty() { @@ -4466,6 +4488,7 @@ fn feature_tools_with_constructor( st: &ModelState, constructor: &mut Option, to_body: &mut Option, + warning: &mut Option, ) -> Result> { *to_body = None; let unnamed = |bodies: Vec| -> Vec { bodies.into_iter().map(Traced::unnamed).collect() }; @@ -4553,15 +4576,22 @@ fn feature_tools_with_constructor( ]; // How far a tool grows past its profile, at most: its sides' run over its length. let growth = len * taper.tan().abs(); + let mut notes = Vec::new(); let tools: Vec = regions .iter() .map(|r| { - let roles = - crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone(), thin: thin_roles.as_ref() }; - let body = kernel::extrude_tapered_between(&start_plane, r, 0.0, len, 0.0, sign, taper, extent.corner)?; + let roles = crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone(), thin: thin_roles.as_ref() }; + let corner = match extent.corner { + Some(c) => c, + None => draft_corner_for(&ss, r, len, taper, &mut notes)?, + }; + let body = kernel::extrude_tapered_between(&start_plane, r, 0.0, len, 0.0, sign, taper, corner)?; Ok(roles.tool(body, None)) }) .collect::>()?; + for note in notes { + append_warning(warning, note); + } // Profiles apart by more than the tools grow (the letters of a text): the tools // are apart too, and go into the body as one, in one boolean rather than one per // profile, each on a body grown or cut by the ones before. (A join meeting several @@ -5898,7 +5928,7 @@ fn replay( // points follow their named faces, as the feature's own did (#2121): a To Object // extrude's copies reach the face where it is now, not where it was picked. let moved = with_named_face_points(doc, st, src).map(|(g, _)| g); - let tools = feature_tools_with_constructor(doc, vals, moved.as_ref().unwrap_or(src), st, &mut None, &mut None)?; + let tools = feature_tools_with_constructor(doc, vals, moved.as_ref().unwrap_or(src), st, &mut None, &mut None, &mut None)?; for (k, m) in mats.iter().enumerate() { let start = copies.len(); for t in &tools { @@ -6426,7 +6456,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::Sweep { operation, targets, .. } => { let mut constructor = None; let mut to_body = None; - let tools = feature_tools_with_constructor(doc, vals, f, st, &mut constructor, &mut to_body)?; + let tools = feature_tools_with_constructor(doc, vals, f, st, &mut constructor, &mut to_body, warning)?; // Cuts through sheet metal become cut-outs of its flat pattern. if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() { let bodies: Vec = tools.iter().map(|t| t.body.clone()).collect(); diff --git a/crates/doc/src/format/pending/draft-corner.rs b/crates/doc/src/format/pending/draft-corner.rs index 6d2079d41b..9f6d7e114b 100644 --- a/crates/doc/src/format/pending/draft-corner.rs +++ b/crates/doc/src/format/pending/draft-corner.rs @@ -1,6 +1,7 @@ -//! An extrude's taper saves how it closes the profile's sharp corners: `extent.corner`, -//! `tangent` or `round` (each offset curve continued straight along its end tangent, or an arc -//! about the corner); absent for `preserve_curvature`, the default, where each wall continues as -//! itself until it meets its neighbour. An older design has none and takes the default, the rule -//! its tapers were built with before SMLib (the walls meeting where the curves' offsets cross). The -//! last release refuses a design with this change (the newer format number). +//! An extrude's taper saves how it closes the profile's sharp corners when the user chose: +//! `extent.corner`, `preserve_curvature` (each wall continues as itself until it meets its +//! neighbour), `tangent` (each offset curve continued straight along its end tangent) or `round` +//! (an arc about the corner). Without it each profile preserves curvature where every curve is a +//! line or an arc and takes the tangent rule where another curve has a corner, the feature's +//! warning saying so; an older design has none and gets that choice. The last release refuses a +//! design with this change (the newer format number). diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index fa442e09fd..11af84fa2c 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -25,7 +25,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("E") .contract(&EXTRUDE_CONTRACT) .output(output_schema_for::) - .params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); corner?: preserve_curvature|tangent|round (how a taper closes a sharp corner its offset opens: each wall continues as itself, a line's plane or an arc's cone, until it meets its neighbour; each continues straight along its end tangent; or an arc about the corner. Default preserve_curvature, which needs lines and arcs at the corners); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile; a pick on a drawn profile that reference geometry, such as the projected outline of the face the sketch is on, cuts into regions takes the whole drawn profile); exact_regions?: bool (each index or point pick is exactly the region it names, as the dialogs send); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?: expr (Two Sides: a second distance the other way from the first side, which may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it); start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"), + .params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); corner?: preserve_curvature|tangent|round (how a taper closes a sharp corner its offset opens: each wall continues as itself, a line's plane or an arc's cone, until it meets its neighbour; each continues straight along its end tangent; or an arc about the corner. Without it, each profile preserves curvature where every curve is a line or an arc and takes tangent where another curve has a corner, and the feature's warning says so; a corner rule given is kept, and refused where the kernel cannot build it); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile; a pick on a drawn profile that reference geometry, such as the projected outline of the face the sketch is on, cuts into regions takes the whole drawn profile); exact_regions?: bool (each index or point pick is exactly the region it names, as the dialogs send); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?: expr (Two Sides: a second distance the other way from the first side, which may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it); start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"), CommandSpec::new("solid.revolve", "Revolve", revolve) .at("SOLID", "CREATE") .icon("revolve") @@ -1109,8 +1109,8 @@ pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result check_expr(s, t, Kind::Angle, cmd, "taper")?; } let corner = match p.get("corner") { - None => solidcraft_kernel::DraftCorner::default(), - Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`corner` must be preserve_curvature, tangent or round"))?, + None => None, + Some(value) => Some(serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`corner` must be preserve_curvature, tangent or round"))?), }; let direction = match str_(p, "direction").map(str::to_ascii_lowercase).as_deref() { None | Some("positive") | Some("one_side") => Direction::Positive, diff --git a/crates/kernel/src/draft_corners.rs b/crates/kernel/src/draft_corners.rs index e7a5a0962d..5ad6b7ba09 100644 --- a/crates/kernel/src/draft_corners.rs +++ b/crates/kernel/src/draft_corners.rs @@ -39,7 +39,7 @@ impl DraftCorner { pub enum CornerRefusal { /// The continued curves do not meet (two parallel lines, two circles that part). Apart, - /// A curve at the corner is neither a line nor an arc: its wall has no exact continuation. + /// A curve of the profile is neither a line nor an arc: its wall has no exact continuation. NotExact, /// A wall at the corner closes before the far end (its arc shrinks to its centre). WallCloses, @@ -97,6 +97,9 @@ pub fn draft_corners(region: &Region2, distance: f64, taper: f64) -> Result Result 0.0) == (d > 0.0); - let curvature = continued(ca, cb, ea, eb, p, d); + let curvature = if free { Err(CornerRefusal::NotExact) } else { continued(ca, cb, ea, eb, p, d) }; let options = if gap { [ CornerOption { rule: DraftCorner::PreserveCurvature, fill: curvature },