From be18bd65735b108f0c744a249c951cac2bb74722 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 9 Oct 2026 22:38:56 +0000 Subject: [PATCH] Variable-radius fillets through middle points: radii at positions along the edge, straight or smooth between kernel::fillet_variable_points(body, edge, start, [(position, radius)...], smooth): the radius law is a B-spline of the edge parameter (straight pieces, or C1 cubic pieces with Catmull-Rom slopes), and since every section's arc is affine in its radius the blend is exactly a rational B-spline (degree 2 across, the law's degree along) with B-spline contact curves on the faces. The largest radius along the edge decides whether the blend fits. Fillet type variable takes mid: [[position, radius expr], ...] and smooth; middle radii are parameters of the feature (Mid radius) and follow user parameters. Tests: straight pieces against (1 - pi/4) L (a^2 + ab + b^2) / 3 per piece, smooth against the integrated law, either end as start, refusals; engine test with a parameter-driven middle radius. Refs #9 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --- crates/doc/src/document.rs | 15 ++- crates/doc/src/eval.rs | 8 +- crates/doc/src/params.rs | 3 +- crates/engine/src/cmd/features.rs | 21 ++++- crates/engine/src/tests.rs | 23 +++++ crates/kernel/src/blend_variable.rs | 137 ++++++++++++++++++++++++++-- crates/kernel/src/lib.rs | 2 +- crates/ui-egui/src/dialogs.rs | 2 +- 8 files changed, 192 insertions(+), 19 deletions(-) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 2301980e..8b0e80ea 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -773,8 +773,16 @@ pub enum FilletStyle { Constant, /// `radius` is the chord: the width across the blend. Chord, - /// `radius` at the end of each edge nearest `start`, `radius2` at the other, linear between. - Variable { radius2: String, start: Vec3 }, + /// `radius` at the end of each edge nearest `start`, `radius2` at the other; `mid` radii at + /// positions between (0 to 1 from the start end); straight between them, or `smooth`. + Variable { + radius2: String, + start: Vec3, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + mid: Vec<(f64, String)>, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + smooth: bool, + }, } impl FilletStyle { @@ -1148,8 +1156,9 @@ impl FeatureKind { } FeatureKind::Fillet { radius, style, .. } => { v.push(radius); - if let FilletStyle::Variable { radius2, .. } = style { + if let FilletStyle::Variable { radius2, mid, .. } = style { v.push(radius2); + v.extend(mid.iter().map(|(_, r)| r.as_str())); } } FeatureKind::Chamfer { distance, distance2, angle, .. } => { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 4bf3fd26..fcf41553 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2620,11 +2620,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: }; let nb = match &f.kind { FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => kernel::fillet_chord(&mb.body, &edges, r)?, - FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start }, .. } => { + FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => { let r2 = val(vals, radius2, Kind::Length)?; + let mut pts = vec![(0.0, r), (1.0, r2)]; + for (t, e) in mid { + pts.push((*t, val(vals, e, Kind::Length)?)); + } let mut b = mb.body.clone(); for p in &edges { - b = kernel::fillet_variable(&b, *p, *start, r, r2)?; + b = kernel::fillet_variable_points(&b, *p, *start, &pts, *smooth)?; } b } diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index c2f0a2f3..5416b6cd 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -75,9 +75,10 @@ impl FeatureKind { FeatureKind::Fillet { radius, style, .. } => match style { crate::FilletStyle::Constant => v.push(("Radius", radius, L)), crate::FilletStyle::Chord => v.push(("Chord length", radius, L)), - crate::FilletStyle::Variable { radius2, .. } => { + crate::FilletStyle::Variable { radius2, mid, .. } => { v.push(("Start radius", radius, L)); v.push(("End radius", radius2, L)); + v.extend(mid.iter_mut().map(|(_, r)| ("Mid radius", r, L))); } }, FeatureKind::Chamfer { distance, distance2, angle, .. } => { diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index ad6f67e5..dd7890be 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "MODIFY") .icon("fillet") .key("F") - .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]"), + .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii)"), CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; 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; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"), CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect) @@ -604,7 +604,24 @@ fn fillet(s: &mut Session, p: &Value) -> Result { check_expr(s, &radius2, Kind::Length, cmd, "radius2")?; let start = p.get("start").and_then(vec3).ok_or_else(|| bad(cmd, "a variable fillet needs `start`: [x,y,z] near the end that takes `radius`"))?; - solvecraft_doc::FilletStyle::Variable { radius2, start } + let mut mid = Vec::new(); + for m in p.get("mid").and_then(Value::as_array).into_iter().flatten().take(100) { + let (Some(t), Some(r)) = (m.get(0).and_then(Value::as_f64), m.get(1)) else { + return Err(bad(cmd, "`mid` entries are [position, radius]")); + }; + if !(t.is_finite() && t > 0.0 && t < 1.0) { + return Err(bad(cmd, "`mid` positions lie between 0 and 1 (from the start end)")); + } + let r = match r { + Value::String(x) => x.clone(), + Value::Number(n) => n.to_string(), + _ => return Err(bad(cmd, "a `mid` radius is a number or an expression")), + }; + check_expr(s, &r, Kind::Length, cmd, "mid radius")?; + mid.push((t, r)); + } + let smooth = p.get("smooth").and_then(Value::as_bool).unwrap_or(false); + solvecraft_doc::FilletStyle::Variable { radius2, start, mid, smooth } } Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord or variable)"))), }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c330c035..8c8697cc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1069,6 +1069,29 @@ fn chord_and_variable_fillets() { let removed = (1.0 - PI / 4.0) * 20.0 * (4.0 + 10.0 + 25.0) / 3.0; assert!(rel(v, 6000.0 - removed) < 1e-4, "{v}"); assert!(s.execute("solid.fillet", &json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable"})).is_err()); + // A radius through a middle point (a user parameter), straight between: 2 → 4 → 2. + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"})); + run(&mut s, "parameters.add", json!({"name": "waist", "expression": "4 mm"})); + run( + &mut s, + "solid.fillet", + json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable", "radius2": 2, "start": [10, 0, 30], "mid": [[0.5, "waist"]]}), + ); + let vol = |s: &Session| solvecraft_kernel::measure(&s.world_state().body("B").unwrap().body).unwrap().volume; + let removed = 2.0 * (1.0 - PI / 4.0) * 10.0 * (4.0 + 8.0 + 16.0) / 3.0; + assert!(rel(vol(&s), 6000.0 - removed) < 1e-4, "{}", vol(&s)); + // The middle radius follows its parameter. + run(&mut s, "parameters.change", json!({"name": "waist", "expression": "3 mm"})); + let removed = 2.0 * (1.0 - PI / 4.0) * 10.0 * (4.0 + 6.0 + 9.0) / 3.0; + assert!(rel(vol(&s), 6000.0 - removed) < 1e-4, "{}", vol(&s)); + assert!( + s.execute( + "solid.fillet", + &json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable", "radius2": 2, "start": [10, 0, 30], "mid": [[1.5, 3]]}) + ) + .is_err() + ); } /// Components export as a STEP assembly: products, occurrences, placements. diff --git a/crates/kernel/src/blend_variable.rs b/crates/kernel/src/blend_variable.rs index 00ca7707..3874a69d 100644 --- a/crates/kernel/src/blend_variable.rs +++ b/crates/kernel/src/blend_variable.rs @@ -56,11 +56,78 @@ fn edge_frame(faces: &[&mt::Face], edge: &mt::Edge) -> Result { /// Fillet one straight edge with radius `r0` at its end nearest `start` and `r1` at the other. pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64) -> Result { + fillet_variable_points(body, edge_at, start, &[(0.0, r0), (1.0, r1)], false) +} + +/// The radius along the edge as a B-spline of the edge parameter (0 at the edge's front): +/// degree, knots and control radii. +struct RadiusLaw { + deg: usize, + knots: Vec, + radii: Vec, +} + +impl RadiusLaw { + /// Through (position, radius) points sorted by position from 0 to 1: straight between them, + /// or `smooth` (cubic pieces with Catmull-Rom slopes, C¹ through every point). + fn through(pts: &[(f64, f64)], smooth: bool) -> RadiusLaw { + if !smooth || pts.len() < 3 { + let mut knots = vec![0.0]; + knots.extend(pts.iter().map(|p| p.0)); + knots.push(1.0); + return RadiusLaw { deg: 1, knots, radii: pts.iter().map(|p| p.1).collect() }; + } + let n = pts.len(); + let at = |i: usize| pts.get(i).copied().unwrap_or((0.0, 0.0)); + let slope = |i: usize| { + let (a, b) = (at(i.saturating_sub(1)), at((i + 1).min(n - 1))); + if b.0 > a.0 { (b.1 - a.1) / (b.0 - a.0) } else { 0.0 } + }; + let mut knots = vec![0.0; 4]; + let mut radii = vec![at(0).1]; + for i in 0..n - 1 { + let ((t0, r0), (t1, r1)) = (at(i), at(i + 1)); + let h = t1 - t0; + radii.extend([r0 + slope(i) * h / 3.0, r1 - slope(i + 1) * h / 3.0, r1]); + knots.extend(if i + 2 == n { vec![1.0; 4] } else { vec![t1; 3] }); + } + RadiusLaw { deg: 3, knots, radii } + } + + /// Greville abscissae: where each control radius sits along the edge. + fn greville(&self) -> Vec { + (0..self.radii.len()).map(|j| self.knots.iter().skip(j + 1).take(self.deg).sum::() / self.deg as f64).collect() + } + + /// The radius at `t` (sampled for checks). + fn eval(&self, t: f64) -> f64 { + let kv = mt::KnotVec::from(self.knots.clone()); + let b = kv.try_bspline_basis_functions(self.deg, t.clamp(0.0, 1.0)).unwrap_or_default(); + b.iter().zip(&self.radii).map(|(x, r)| x * r).sum() + } +} + +/// Fillet one straight edge with a radius that varies along it: (position, radius) points with +/// positions 0 (the end nearest `start`) to 1 (the other end) and any between, the radius +/// straight between them or `smooth`. +pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &[(f64, f64)], smooth: bool) -> Result { body.require_brep("fillet")?; - for r in [r0, r1] { - if !(r.is_finite() && r > 1e-6 && r < 1e6) { + if points.len() < 2 || points.len() > 100 { + return Err(KernelError::Invalid("a variable radius needs 2…100 points".into())); + } + for (t, r) in points { + if !(r.is_finite() && *r > 1e-6 && *r < 1e6) { return Err(KernelError::Invalid("fillet radii must be positive".into())); } + if !(t.is_finite() && (0.0..=1.0).contains(t)) { + return Err(KernelError::Invalid("radius positions run from 0 to 1 along the edge".into())); + } + } + let mut pts = points.to_vec(); + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-9); + if pts.first().map(|p| p.0) != Some(0.0) || pts.last().map(|p| p.0) != Some(1.0) || pts.len() < 2 { + return Err(KernelError::Invalid("give the radius at both ends of the edge".into())); } let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?; let size = body.size(); @@ -78,8 +145,17 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64 let faces: Vec<&mt::Face> = shell.face_iter().collect(); let fr = edge_frame(&faces, &edge)?; let (v0, v1) = (edge.absolute_front().clone(), edge.absolute_back().clone()); - // r0 at the end nearest `start` (the edge keeps its own direction). - let (ra, rb) = if vtx(&v1).dist(start) < vtx(&v0).dist(start) { (r1, r0) } else { (r0, r1) }; + // Positions from the end nearest `start` (the edge keeps its own direction). + if vtx(&v1).dist(start) < vtx(&v0).dist(start) { + pts = pts.iter().rev().map(|(t, r)| (1.0 - t, *r)).collect(); + } + let law = RadiusLaw::through(&pts, smooth); + let (ra, rb) = (law.eval(0.0), law.eval(1.0)); + // The largest radius along the edge decides whether the blend fits. + let rmax = (0..=200).map(|k| law.eval(k as f64 / 200.0)).fold(0.0, f64::max); + if (0..=200).any(|k| law.eval(k as f64 / 200.0) <= 1e-6) { + return Err(KernelError::Invalid("the radius must stay positive along the edge".into())); + } let (p0, p1) = (vtx(&v0), vtx(&v1)); let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?; let tol = (size * 1e-7).max(1e-9); @@ -114,7 +190,7 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64 } let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) }; let along = (other - p).dot(t); - if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= back(r) + tol { + if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= back(r.max(rmax)) + tol { return Err(unsupported("the blend is larger than the neighbouring faces")); } } @@ -133,8 +209,18 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64 }; let c0 = builder::circle_arc(&a0, &b0, p3(arc_mid(p0, s0, ra))); let c1 = builder::circle_arc(&a1, &b1, p3(arc_mid(p1, s1, rb))); - let la = builder::line(&a0, &a1); - let lb = builder::line(&b0, &b1); + // Contact curves: the edge moved along each face by the setback of the radius there. + let g = law.greville(); + let contact = |t: Vec3, a: &mt::Vertex, b: &mt::Vertex| -> Result { + if law.deg == 1 && law.radii.len() == 2 { + return Ok(builder::line(a, b)); + } + let ctrl: Vec = g.iter().zip(&law.radii).map(|(gj, rj)| p3(p0 + (p1 - p0) * *gj + t * back(*rj))).collect(); + let c = mt::BSplineCurve::try_new(mt::KnotVec::from(law.knots.clone()), ctrl).map_err(|e| KernelError::Failed(format!("fillet: {e}")))?; + mt::Edge::try_new(a, b, mt::Curve::BSplineCurve(c)).map_err(|e| KernelError::Failed(format!("fillet: {e}"))) + }; + let la = contact(fr.t1, &a0, &a1)?; + let lb = contact(fr.t2, &b0, &b1)?; let mut subst: HashMap = HashMap::new(); for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] { for (e, nv) in [(&end.e1, a), (&end.e2, b)] { @@ -166,8 +252,11 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64 // weight sin(φ/2)) at each end, joined linearly. let w = (fr.phi / 2.0).sin(); let h = |p: Vec3, wt: f64| mt::Vector4::new(p.x * wt, p.y * wt, p.z * wt, wt); - let ctrl = vec![vec![h(pa0, 1.0), h(pa1, 1.0)], vec![h(p0, w), h(p1, w)], vec![h(pb0, 1.0), h(pb1, 1.0)]]; - let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)), ctrl); + let row = |t: Vec3, wt: f64, off: bool| -> Vec { + g.iter().zip(&law.radii).map(|(gj, rj)| h(p0 + (p1 - p0) * *gj + if off { t * back(*rj) } else { Vec3::ZERO }, wt)).collect() + }; + let ctrl = vec![row(fr.t1, 1.0, true), row(fr.t1, w, false), row(fr.t2, 1.0, true)]; + let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::from(law.knots.clone())), ctrl); let mut surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bsp)); // Outward: away from the arc centre on a convex edge, toward it on a concave one. let (c, mid) = (p0 + fr.t1 * s0 + fr.n1 * (ra * fr.side), arc_mid(p0, s0, ra)); @@ -260,6 +349,36 @@ mod tests { assert!(fillet_variable(&b, Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0), 2.0, 50.0).is_err()); } + /// A radius through a middle point: straight pieces (exact: each piece removes + /// (1 − π/4) L (a² + ab + b²) / 3), and smooth (the removed volume integrates the law). + #[test] + fn variable_radius_through_a_middle_point() { + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap(); + let (at, start) = (Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0)); + let k = 1.0 - std::f64::consts::FRAC_PI_4; + let pts = [(0.0, 2.0), (0.5, 4.0), (1.0, 2.0)]; + let f = fillet_variable_points(&b, at, start, &pts, false).unwrap(); + let removed = 2.0 * k * 10.0 * (4.0 + 8.0 + 16.0) / 3.0; + let v = measure(&f).unwrap().volume; + assert!(rel(v, 6000.0 - removed) < 1e-4, "{v} vs {}", 6000.0 - removed); + assert_eq!(f.unmeshed_faces(), 0); + let s = fillet_variable_points(&b, at, start, &pts, true).unwrap(); + let law = RadiusLaw::through(&pts, true); + let n = 20000; + let integral: f64 = (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::() / n as f64 * 20.0; + assert!((law.eval(0.5) - 4.0).abs() < 1e-12 && (law.eval(0.0) - 2.0).abs() < 1e-12); + let v = measure(&s).unwrap().volume; + assert!(rel(v, 6000.0 - k * integral) < 1e-4, "{v} vs {}", 6000.0 - k * integral); + // Unsorted, from the other end: the same. + let r = fillet_variable_points(&b, at, Vec3::new(10.0, 20.0, 30.0), &[(1.0, 2.0), (0.0, 2.0), (0.5, 4.0)], true).unwrap(); + assert!(rel(measure(&r).unwrap().volume, v) < 1e-9); + // A middle radius too big for the faces, missing ends, bad positions: refused. + assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (0.5, 40.0), (1.0, 2.0)], false).is_err()); + assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (0.5, 4.0)], false).is_err()); + assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (f64::NAN, 4.0), (1.0, 2.0)], false).is_err()); + assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (1.0, -1.0)], false).is_err()); + } + #[test] fn chord_length_on_a_right_angle() { let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap(); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index d40ff5e8..44344d4d 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -50,7 +50,7 @@ mod topo; pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet}; pub use blend_corner::fillet_radii; -pub use blend_variable::{fillet_chord, fillet_variable}; +pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points}; pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint}; pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus}; pub use delete_face::delete_faces; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c065080d..0460a48d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2790,7 +2790,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { d.extra.insert("type".into(), json!("chord")); } - FilletStyle::Variable { radius2, start } => { + FilletStyle::Variable { radius2, start, .. } => { d.extra.insert("type".into(), json!("variable")); d.extra.insert("radius2".into(), json!(radius2)); // The start was the first edge's last point: ends swapped.