feat: drag a fillet's radius spline by its handles on the edge (#752)

A spline fillet's radius can now be dragged by handles on the edge, and the spline radius saves under format 17.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-13 12:35:42 -04:00
1 parent 159f9913c1
commit 5267c96ab2
13 files changed
+414 -31

No files matched your search

+1 -1
View File
@@ -227,7 +227,7 @@ impl FeatureKind {
}
FeatureKind::Fillet { edges, style, .. } => {
edges.iter_mut().for_each(pt);
if let crate::FilletStyle::Variable { start, .. } = style {
if let crate::FilletStyle::Variable { start, .. } | crate::FilletStyle::Spline { start, .. } = style {
pt(start);
}
}
+18 -3
View File
@@ -994,6 +994,10 @@ pub enum FilletStyle {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
smooth: bool,
},
/// The radius along each edge as a clamped B-spline of the position from the end nearest
/// `start` (0) to the other (1): `radius` is its first control radius and `radii` the rest,
/// its degree what `knots` leave (knots = control radii + degree + 1).
Spline { start: Vec3, knots: Vec<f64>, radii: Vec<String> },
}
/// A fillet radius too large for the geometry around its edges: refused (`Exact`, the default),
@@ -1019,6 +1023,13 @@ impl FilletStyle {
pub fn is_constant(&self) -> bool {
*self == FilletStyle::Constant
}
/// The spline of a [`FilletStyle::Spline`] with its control radii `values` (the first is
/// the feature's `radius`).
pub fn spline(knots: &[f64], values: Vec<f64>) -> solidcraft_kernel::RadiusSpline {
let degree = knots.len().saturating_sub(values.len() + 1);
solidcraft_kernel::RadiusSpline { degree, knots: knots.to_vec(), radii: values }
}
}
/// A face picked by a point on it, on a named body.
@@ -1514,9 +1525,13 @@ impl FeatureKind {
}
FeatureKind::Fillet { radius, style, .. } => {
v.push(radius);
if let FilletStyle::Variable { radius2, mid, .. } = style {
v.push(radius2);
v.extend(mid.iter().map(|(_, r)| r.as_str()));
match style {
FilletStyle::Variable { radius2, mid, .. } => {
v.push(radius2);
v.extend(mid.iter().map(|(_, r)| r.as_str()));
}
FilletStyle::Spline { radii, .. } => v.extend(radii.iter().map(String::as_str)),
FilletStyle::Constant | FilletStyle::Chord => {}
}
}
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
+45 -10
View File
@@ -307,6 +307,21 @@ fn tapered_note(tapers: &[Option<kernel::Taper>]) -> Option<String> {
Some(format!("the radius tapers to the room on {n} of {} edges, down to {} mm", tapers.len(), show_mm(low)))
}
/// Blend `edges` one at a time with `blend`, each blend's lineage over the one before: the
/// result and the lineage of what the last blend started from.
fn one_by_one(input: Traced, edges: &[Vec3], blend: impl Fn(&kernel::Body, Vec3) -> kernel::Result<kernel::Body>) -> Result<(kernel::Body, Traced)> {
let mut b = input;
let mut from = None;
for p in edges {
let next = Traced::step(blend(&b.body, *p)?, &[&b]);
from = Some(std::mem::replace(&mut b, next));
}
match from {
Some(prev) => Ok((b.body, prev)),
None => Err(DocError::Invalid("no edges to fillet".into())),
}
}
/// Add `note` to a feature's warning.
fn append_warning(warning: &mut Option<String>, note: String) {
*warning = Some(match warning.take() {
@@ -333,6 +348,11 @@ impl ModelState {
self.fitted.get(&feature)?.iter().fold(None, |acc, r| Some(acc.map_or((*r, *r), |(lo, hi): (f64, f64)| (lo.min(*r), hi.max(*r)))))
}
/// Each edge fillet `feature` tapered (a point on it) with the radius spline it was given.
pub fn tapered(&self, feature: u64) -> Option<&[(Vec3, kernel::Taper)]> {
self.tapered.get(&feature).map(Vec::as_slice)
}
/// Each edge fillet `feature` blended (a point on it) with the radius it used, when it
/// reduced a radius too large for some of its edges.
pub fn fitted_edges(&self, feature: u64) -> Option<Vec<(Vec3, f64)>> {
@@ -364,6 +384,9 @@ pub struct ModelState {
/// The radius per edge a fillet used when it reduced a radius too large for some of its edges
/// (by feature id, see `FilletFit::Reduce`).
fitted: BTreeMap<u64, Vec<f64>>,
/// The radius spline of each edge a fillet tapered (a point on the edge with it, by feature
/// id, see `FilletFit::Taper`).
tapered: BTreeMap<u64, Vec<(Vec3, kernel::Taper)>>,
}
impl ModelState {
@@ -395,6 +418,16 @@ impl ModelState {
r.to_bits().hash(&mut h);
}
}
for (feature, tapers) in &self.tapered {
feature.hash(&mut h);
for (p, t) in tapers {
let s = &t.spline;
for x in [p.x, p.y, p.z, t.start.x, t.start.y, t.start.z].iter().chain(&s.knots).chain(&s.radii) {
x.to_bits().hash(&mut h);
}
s.degree.hash(&mut h);
}
}
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
h.finish()
@@ -3882,23 +3915,25 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
for (t, e) in mid {
pts.push((*t, val(vals, e, Kind::Length)?));
}
// One edge at a time: each blend's lineage over the one before.
let mut b = input;
let mut from = None;
for p in &edges {
let next = Traced::step(kernel::fillet_variable_points(&b.body, *p, *start, &pts, *smooth)?, &[&b]);
from = Some(std::mem::replace(&mut b, next));
}
match from {
Some(prev) => (b.body, prev),
None => return Err(DocError::Invalid("no edges to fillet".into())),
one_by_one(input, &edges, |b, p| kernel::fillet_variable_points(b, p, *start, &pts, *smooth))?
}
FeatureKind::Fillet { style: crate::FilletStyle::Spline { start, knots, radii }, .. } => {
let mut values = vec![r];
for e in radii {
values.push(val(vals, e, Kind::Length)?);
}
let law = crate::FilletStyle::spline(knots, values);
one_by_one(input, &edges, |b, p| kernel::fillet_variable_spline(b, p, *start, &law))?
}
FeatureKind::Fillet { fit: crate::FilletFit::Taper, .. } => {
let (b, tapers) = kernel::fillet_tapered(&mb.body, &edges, r)?;
if let Some(note) = tapered_note(&tapers) {
append_warning(warning, note);
}
let tapered: Vec<(Vec3, kernel::Taper)> = edges.iter().copied().zip(tapers).filter_map(|(p, t)| Some((p, t?))).collect();
if !tapered.is_empty() {
st.tapered.insert(f.id, tapered);
}
(b, input)
}
FeatureKind::Fillet { fit, .. } => {
+3
View File
@@ -91,6 +91,9 @@
//! edited.
//! A fillet's `fit` can be `taper` (the radius follows the room along each edge it doesn't
//! fit). Fillets with `exact` or `reduce` save as before; 0.8.0 would refuse the unknown fit.
//! A fillet's `type` can be `spline` (the radius along each edge a B-spline: `start`, `knots`,
//! and the control radii after `radius` in `radii`). Other fillets save as before; 0.8.0 would
//! refuse the unknown type.
use serde_json::Value;
+5
View File
@@ -83,6 +83,11 @@ impl FeatureKind {
v.push(("End radius", radius2, L));
v.extend(mid.iter_mut().map(|(_, r)| ("Mid radius", r, L)));
}
crate::FilletStyle::Spline { radii, .. } => {
v.push(("Start radius", radius, L));
let n = radii.len();
v.extend(radii.iter_mut().enumerate().map(|(i, r)| (if i + 1 == n { "End radius" } else { "Control radius" }, r, L)));
}
},
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
v.push(("Distance", distance, L));