doc, engine: solid.fillet setbacks: [{vertex, distance}]

FeatureKind::Fillet gains setbacks (serde default: none, omitted when empty),
each a corner vertex and a length expression; a fillet with setbacks is
evaluated by kernel::fillet_setback. Constant-radius exact fillets with
rolling corners only (refused otherwise, in the command and in evaluation).
One distance per corner: SMLib keeps one setback per corner, not per edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-26 11:14:20 -04:00
parent f85343879d
commit c854239d18
5 changed files with 74 additions and 1 deletions
+14
View File
@@ -683,6 +683,10 @@ pub enum FeatureKind {
/// (default) or a G2 patch repeating each blend up to the second order.
#[serde(default, skip_serializing_if = "FilletCorner::is_rolling")]
corner: FilletCorner,
/// Corners set back: each corner's patch starts its distance along every edge there
/// from the vertex instead of where the rounds meet (a constant-radius fillet only).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
setbacks: Vec<CornerSetback>,
/// Round each picked edge's tangent chain with it (the default: edges continuing it
/// smoothly at its ends, as far as they go), or only the picked edges.
#[serde(default = "yes", skip_serializing_if = "is_yes")]
@@ -1841,6 +1845,16 @@ pub enum FeatureKind {
},
}
/// A fillet corner set back: the vertex at `vertex`, where every edge is rounded, has its corner
/// patch start `distance` along each of its edges from the vertex, the rounds running whole up to
/// their sections there. One distance for all of a corner's edges.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct CornerSetback {
pub vertex: Position,
#[walk(length = "Setback")]
pub distance: Expr,
}
/// What fills the corner where three blended edges meet.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
+17
View File
@@ -3498,6 +3498,13 @@ fn blend_body(
// The blended body, and the lineage of what the last blend started from.
let (nb, from) = match &f.kind {
FeatureKind::Fillet { corner: crate::FilletCorner::G2, .. } => (kernel::fillet_g2_corners(&mb.body, edges, r)?, input),
FeatureKind::Fillet { setbacks, .. } if !setbacks.is_empty() => {
let mut corners = Vec::with_capacity(setbacks.len());
for c in setbacks {
corners.push((c.vertex.0, val(vals, &c.distance, Kind::Length)?));
}
(kernel::fillet_setback(&mb.body, edges, r, &corners)?, input)
}
FeatureKind::Fillet { style: crate::FilletStyle::Curvature, .. } => (kernel::fillet_g2(&mb.body, edges, r)?, input),
FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => (kernel::fillet_chord(&mb.body, edges, r)?, input),
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => {
@@ -6732,6 +6739,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
// Each edge is found on its own body (picked over several bodies, the edges of each
// are blended on it), all against the bodies as they were before this feature.
// Corner setbacks likewise: SMLib sets back the corners of constant-radius rounds.
if let FeatureKind::Fillet { setbacks, corner, style, fit, .. } = &f.kind
&& !setbacks.is_empty()
&& (!corner.is_rolling() || !matches!(style, crate::FilletStyle::Constant) || !matches!(fit, crate::FilletFit::Exact))
{
return Err(DocError::Invalid(
"corner setbacks are built for a constant-radius fillet (type: constant, fit: exact, corner: rolling) only".into(),
));
}
let on = blend_bodies(st, f, body, &crate::plain(edges))?;
let mut groups: Vec<(usize, Vec<usize>)> = Vec::new();
for (i, ti) in on.iter().enumerate() {
@@ -7770,6 +7786,7 @@ mod chain_tests {
style: crate::FilletStyle::Constant,
fit: Default::default(),
corner: Default::default(),
setbacks: Vec::new(),
tangent_chain: true,
},
None,
+25
View File
@@ -262,6 +262,7 @@ fn box_fillet_cut_timeline() {
style: Default::default(),
fit: Default::default(),
corner: Default::default(),
setbacks: Vec::new(),
tangent_chain: true,
},
None,
@@ -335,6 +336,7 @@ fn errors_are_reported_not_fatal() {
style: Default::default(),
fit: Default::default(),
corner: Default::default(),
setbacks: Vec::new(),
tangent_chain: true,
},
None,
@@ -747,3 +749,26 @@ fn a_join_splits_off_only_what_touches_along_an_edge() {
assert!(rel(other.1, arm) < 1e-6 && other.2 == 1, "the arm: {bodies:?}");
assert_eq!(kept, Some(KeptSeparate::AlongEdge));
}
/// A fillet saved before corner setbacks reads with none and writes no `setbacks`; one with a
/// setback keeps it through a save, its distance an expression.
#[test]
fn fillet_setbacks_default_to_none_and_round_trip() {
let old = serde_json::json!({"type": "fillet", "edges": [[30.0, 40.0, 20.0]], "radius": "5"});
let kind: FeatureKind = serde_json::from_value(old).unwrap();
let FeatureKind::Fillet { setbacks, .. } = &kind else { panic!("{kind:?}") };
assert!(setbacks.is_empty());
assert!(serde_json::to_value(&kind).unwrap().get("setbacks").is_none());
let set = FeatureKind::Fillet {
edges: vec![Position(Vec3::new(30.0, 40.0, 20.0))],
radius: "5".into(),
body: None,
style: Default::default(),
fit: Default::default(),
corner: Default::default(),
setbacks: vec![CornerSetback { vertex: Position(Vec3::new(60.0, 40.0, 20.0)), distance: "8 mm".into() }],
tangent_chain: true,
};
let back: FeatureKind = serde_json::from_value(serde_json::to_value(&set).unwrap()).unwrap();
assert_eq!(back, set);
}
+16 -1
View File
@@ -49,7 +49,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: a patch repeating each blend's surface up to the second order, constant-radius exact fillets only, refused where it cannot be built); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: a patch repeating each blend's surface up to the second order, constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets with rolling corners only); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
@@ -1654,6 +1654,20 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
if !corner.is_rolling() && (!style.is_constant() || !fit.is_exact()) {
return Err(bad(cmd, "`corner: g2` is built for a constant-radius fillet (`type: constant`, `fit: exact`) only"));
}
// Corner setbacks: each a vertex and the distance its patch starts along the edges there.
let mut setbacks = Vec::new();
if let Some(list) = p.get("setbacks") {
let list = list.as_array().ok_or_else(|| bad(cmd, "`setbacks` is a list of {vertex: [x,y,z], distance: expr}"))?;
for item in list {
let vertex = item.get("vertex").and_then(vec3).ok_or_else(|| bad(cmd, "each setback needs `vertex`: [x,y,z] at the corner"))?;
let distance = expr(item, "distance").ok_or_else(|| bad(cmd, "each setback needs `distance`: a length"))?;
check_expr(s, &distance, Kind::Length, cmd, "setback distance")?;
setbacks.push(solidcraft_doc::CornerSetback { vertex: Position(vertex), distance });
}
if !setbacks.is_empty() && (!style.is_constant() || !fit.is_exact() || !corner.is_rolling()) {
return Err(bad(cmd, "`setbacks` are built for a constant-radius fillet (`type: constant`, `fit: exact`, `corner: rolling`) only"));
}
}
let mut out = add_feature(
s,
p,
@@ -1664,6 +1678,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
style,
fit,
corner,
setbacks,
tangent_chain: bool_(p, "tangent_chain").unwrap_or(true),
},
)?;
+2
View File
@@ -1048,6 +1048,7 @@ mod tests {
style: Default::default(),
fit: Default::default(),
corner: Default::default(),
setbacks: Vec::new(),
tangent_chain: true,
},
None,
@@ -1134,6 +1135,7 @@ mod tests {
style: Default::default(),
fit: Default::default(),
corner: Default::default(),
setbacks: Vec::new(),
tangent_chain: true,
},
None,