doc: an extrude's taper saves how it closes the profile's corners

Extent.corner (preserve_curvature by default, tangent or round), saved
only when not the default (one pending format change), and the
solid.extrude `corner` parameter. draft_corner_options reports what each
rule gives at each corner of a feature's profiles, for a dialog to offer
the others where the chosen one is a poor fit.

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 09:11:24 -04:00
parent 384c5866f1
commit 0459614457
10 changed files with 46 additions and 6 deletions
+8
View File
@@ -274,6 +274,10 @@ 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 {
@@ -301,6 +305,10 @@ 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,
/// 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>,
+15 -1
View File
@@ -2440,6 +2440,20 @@ fn refuse_loft_holes(r: &Region2) -> Result<()> {
Err(DocError::Invalid("not supported yet: lofting a section with holes to a point".into()))
}
/// What each way of closing a taper's corners ([`kernel::DraftCorner`]) gives at each sharp
/// corner of the profiles `sel` picks in `ss`, extruded `distance` with a taper of `taper` radians
/// (positive grows the profile), the profiles merged as an extrude merges them: per corner, how
/// far each offset curve runs on (or is trimmed), whether the corner cuts in, or why the rule
/// refuses it. A dialog offers the other rules where the chosen one is a poor fit.
pub fn draft_corner_options(ss: &SolvedSketch, sel: &ProfileSel, distance: f64, taper: f64) -> Result<Vec<kernel::CornerReport>> {
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, sel)?);
let mut out = Vec::new();
for r in &regions {
out.extend(kernel::draft_corners(r, distance, taper)?);
}
Ok(out)
}
pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region2>> {
let ps = &ss.profiles;
if ps.is_empty() {
@@ -4560,7 +4574,7 @@ fn feature_tools_with_constructor(
.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, kernel::DraftCorner::default())?;
let body = kernel::extrude_tapered_between(&start_plane, r, 0.0, len, 0.0, sign, taper, extent.corner)?;
Ok(roles.tool(body, None))
})
.collect::<Result<_>>()?;
+1
View File
@@ -1630,6 +1630,7 @@ mod tests {
start_offset: None,
through_all: false,
taper: Some("0 deg".into()),
corner: Default::default(),
to: Some(crate::FacePick(solidcraft_geom::Vec3::new(0.0, 0.0, 10.0))),
to_offset: Some("offset + 1 mm".into()),
from: Some(crate::FacePick(solidcraft_geom::Vec3::new(0.0, 0.0, 5.0))),
@@ -0,0 +1,6 @@
//! 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).
+1
View File
@@ -464,6 +464,7 @@ mod tests {
start_offset: None,
through_all: false,
taper: None,
corner: Default::default(),
to: None,
to_offset: None,
from: None,
+4 -4
View File
@@ -76,10 +76,10 @@ pub use document::*;
pub use eval::KeptResults;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SavedModel, SolvedSketch, ThreadInfo, blend_target,
curve_pick_error, edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, map_wire,
metric_thread_sizes, object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles,
sketch_hole_direction, sketch_line_plane, state_in_frame, state_in_frame_at, suggested_thread, tangent_face_chain, through_all_distance,
world_state, world_state_reusing,
curve_pick_error, draft_corner_options, edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names,
map_wire, metric_thread_sizes, object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention,
select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame, state_in_frame_at, suggested_thread, tangent_face_chain,
through_all_distance, world_state, world_state_reusing,
};
pub use expr::Expr;
pub use names::{BodyName, FeatureId, FeatureName, SketchId, copy_name};
+2
View File
@@ -453,6 +453,7 @@ mod tests {
start_offset: None,
through_all: false,
taper: None,
corner: Default::default(),
to: None,
to_offset: None,
from: None,
@@ -635,6 +636,7 @@ mod tests {
start_offset: None,
through_all: false,
taper: None,
corner: Default::default(),
to: None,
to_offset: None,
from: None,
+2
View File
@@ -40,6 +40,7 @@ fn plate_doc() -> (Document, u64) {
start_offset: None,
through_all: false,
taper: None,
corner: Default::default(),
to: None,
to_offset: None,
from: None,
@@ -281,6 +282,7 @@ fn box_fillet_cut_timeline() {
start_offset: None,
through_all: false,
taper: None,
corner: Default::default(),
to: None,
to_offset: None,
from: None,
+1
View File
@@ -50,6 +50,7 @@ impl Walk for solidcraft_sketch::PatternAlignment {}
impl Walk for solidcraft_kernel::StepNode {}
impl Walk for crate::document::Operation {}
impl Walk for solidcraft_kernel::Continuity {}
impl Walk for solidcraft_kernel::DraftCorner {}
impl Walk for crate::document::Direction {}
impl Walk for crate::document::FilletFit {}
impl Walk for crate::document::FilletCorner {}
+6 -1
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); 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. 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"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
@@ -1108,6 +1108,10 @@ pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent>
if let Some(t) = &taper {
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"))?,
};
let direction = match str_(p, "direction").map(str::to_ascii_lowercase).as_deref() {
None | Some("positive") | Some("one_side") => Direction::Positive,
Some("negative") | Some("reverse") | Some("flip") => Direction::Negative,
@@ -1136,6 +1140,7 @@ pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent>
start_offset,
through_all,
taper,
corner,
to: p.get("to").and_then(vec3).map(FacePick),
to_offset: expr(p, "to_offset"),
from: p.get("from").and_then(vec3).map(FacePick),