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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-22 14:29:45 -04:00
parent 84bd2daadc
commit d0ff1dcfa4
5 changed files with 57 additions and 24 deletions
+7 -8
View File
@@ -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<Expr>,
/// 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<solidcraft_kernel::DraftCorner>,
/// 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<FacePick>,
+35 -5
View File
@@ -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<String>) -> Result<kernel::DraftCorner> {
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<Vec<Region2>> {
let ps = &ss.profiles;
if ps.is_empty() {
@@ -4466,6 +4488,7 @@ fn feature_tools_with_constructor(
st: &ModelState,
constructor: &mut Option<ExtrudeConstructor>,
to_body: &mut Option<String>,
warning: &mut Option<String>,
) -> Result<Vec<Traced>> {
*to_body = None;
let unnamed = |bodies: Vec<Body>| -> Vec<Traced> { 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<Traced> = 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::<Result<_>>()?;
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<String, Value>, 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<Body> = tools.iter().map(|t| t.body.clone()).collect();
@@ -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).
+3 -3
View File
@@ -25,7 +25,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("E")
.contract(&EXTRUDE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.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<Extent>
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,
+5 -2
View File
@@ -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<Vec<
let segs = &outer.segs;
let d = distance * taper.tan();
let n = segs.len();
// The walls are built as one exact set: a profile with a sharp corner keeps its curvature only
// when every curve is a line or an arc.
let free = segs.iter().any(|s| matches!(carrier(s, d), Carrier::Free));
let mut out = Vec::new();
for k in 0..n {
let (Some(a), Some(b)) = (segs.get((k + n - 1) % n), segs.get(k)) else { continue };
@@ -111,7 +114,7 @@ pub fn draft_corners(region: &Region2, distance: f64, taper: f64) -> Result<Vec<
let (ea, eb) = (a.end() + right(ta) * d, b.start() + right(tb) * d);
// A left turn opens a gap when the profile grows, a right turn when it shrinks.
let gap = (turn > 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 },