diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index feee19af..05eb5c6b 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -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); } } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 5368ef75..adf2aa3f 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -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, radii: Vec }, } /// 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) -> 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, .. } => { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 268d27fa..23bc4e83 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -307,6 +307,21 @@ fn tapered_note(tapers: &[Option]) -> Option { 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) -> 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, 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> { @@ -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>, + /// The radius spline of each edge a fillet tapered (a point on the edge with it, by feature + /// id, see `FilletFit::Taper`). + tapered: BTreeMap>, } 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, 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, .. } => { diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index a836f724..eaac25d3 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -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; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 1098ef99..7af59b25 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -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)); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 56e9fd30..3b27016b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -441,10 +441,18 @@ fn input_keys(kind: &FeatureKind) -> Vec<(String, solidcraft_doc::expr::Kind)> { if let Some((key, _)) = keys.first_mut() { *key = "fillet.radius".into(); } - if let solidcraft_doc::FilletStyle::Variable { mid, .. } = style { - for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) { - *key = format!("fillet.mid.{}", position.to_bits()); + match style { + solidcraft_doc::FilletStyle::Variable { mid, .. } => { + for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) { + *key = format!("fillet.mid.{}", position.to_bits()); + } } + solidcraft_doc::FilletStyle::Spline { .. } => { + for (i, (key, _)) in keys.iter_mut().enumerate().skip(1) { + *key = format!("fillet.control.{i}"); + } + } + solidcraft_doc::FilletStyle::Constant | solidcraft_doc::FilletStyle::Chord => {} } } keys diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 0bcf62e7..847944a0 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; one end may be 0, where the round closes to the corner); 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); 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; body?; type?: constant|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); 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; 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) @@ -839,7 +839,32 @@ fn fillet(s: &mut Session, p: &Value) -> Result { let smooth = p.get("smooth").and_then(Value::as_bool).unwrap_or(false); solidcraft_doc::FilletStyle::Variable { radius2, start, mid, smooth } } - Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord or variable)"))), + Some("spline") => { + let start = p + .get("start") + .and_then(vec3) + .ok_or_else(|| bad(cmd, "a spline fillet needs `start`: [x,y,z] near the end its positions start from"))?; + let knots: Vec = p.get("knots").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_f64).collect(); + let mut radii = Vec::new(); + for r in p.get("radii").and_then(Value::as_array).into_iter().flatten().take(100) { + let r = match r { + Value::String(x) => x.clone(), + Value::Number(n) => n.to_string(), + _ => return Err(bad(cmd, "a control radius is a number or an expression")), + }; + check_expr(s, &r, Kind::Length, cmd, "control radius")?; + radii.push(r); + } + // All control radii (`radius` and `radii`) plus the degree (1…3) plus one. + if radii.is_empty() || !(radii.len() + 3..=radii.len() + 5).contains(&knots.len()) { + return Err(bad( + cmd, + "a spline fillet needs `radii` (the control radii after `radius`) and `knots` (as many as all control radii, the degree 1…3 and one more)", + )); + } + solidcraft_doc::FilletStyle::Spline { start, knots, radii } + } + Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord, variable or spline)"))), }; let fit = match str_(p, "fit").map(str::to_ascii_lowercase).as_deref() { None | Some("exact") => solidcraft_doc::FilletFit::Exact, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f759db2e..1633d3c2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2971,6 +2971,37 @@ fn fillet_fit_taper_follows_the_room_along_the_edge() { assert!(w.contains("on 1 of 1 edges, down to 0 mm"), "{w}"); } +#[test] +fn spline_fillet_takes_over_a_taper() { + // A tapered fillet keeps each edge's radius spline; given back as a `spline` fillet it makes + // the same part, and its control radii are then the user's to change. + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 2], [0, 20]], "closed": true})); + run(&mut s, "solid.extrude", json!({"distance": 15})); + let edge = json!([[20, 0, 15]]); + let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 3, "fit": "taper"})); + let id = out["feature"].as_u64().unwrap(); + let tapered = s.model.result(id).and_then(|r| r.state.tapered(id).map(<[_]>::to_vec)).unwrap(); + let [(_, t)] = &tapered[..] else { panic!("{tapered:?}") }; + let tapered_volume = volume(&mut s); + let radii: Vec = t.spline.radii.iter().skip(1).map(|r| json!(r)).collect(); + let spline = json!({"edges": edge, "radius": t.spline.radii[0], "type": "spline", "start": [t.start.x, t.start.y, t.start.z], "knots": t.spline.knots, "radii": radii}); + run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.fillet", "params": spline})); + assert!(rel(volume(&mut s), tapered_volume) < 1e-9, "{} vs {tapered_volume}", volume(&mut s)); + let json = serde_json::to_string(&s.doc.features).unwrap(); + assert!(json.contains("\"type\":\"spline\""), "{json}"); + // A control radius raised in the middle: more material goes. + let mut raised = spline.clone(); + raised["radii"][2] = json!("4 mm"); + run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.fillet", "params": raised})); + assert!(volume(&mut s) < tapered_volume); + // Knots that don't match the radii are refused. + let mut bad = spline; + bad["knots"] = json!([0, 1]); + assert!(s.execute("solid.fillet", &bad).is_err()); +} + #[test] fn fillet_fit_reduce_keeps_the_radius_where_it_fits() { // A 2 mm fin on a block: its top edge can't take 4, a corner of the block far from it can. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a1df2e70..21f8bc4a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -302,6 +302,11 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, } dir = None; } + // A fillet with a radius spline is shaped by its handles along the edge; the arrow would sit + // on one of them. + if radius_spline_handles(app, ui, painter, proj, d) { + dir = None; + } let Some((label, kind, value)) = d.primary() else { return }; let len = all_span.or_else(|| { if to_span.is_some() && value.trim().is_empty() { Some(0.0) } else { app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()) } @@ -404,6 +409,126 @@ fn drag_arrow( Some(ts) } +/// A fillet's radius spline on its first edge (a spline fillet, or the spline a taper gave it): +/// the radius curve drawn into the body from the edge, and a handle per control radius at its +/// Greville position, that far from the edge. Dragging a handle sets its control radius (snapped +/// unless Alt/Ctrl); the first drag on a taper makes the fillet a spline fillet starting from the +/// taper's spline. Whether the handles are shown. +fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog) -> bool { + use serde_json::{Value, json}; + let t = Tokens::get(); + let Kind::Fillet { chamfer: false, .. } = d.kind else { return false }; + let st = app.session.world_state(); + let Some((mesh, index)) = d.inputs.first().and_then(|i| { + i.items.iter().find_map(|x| match x { + Sel::Edge { body, index, .. } => Some((st.body(body)?.mesh(), *index)), + _ => None, + }) + }) else { + return false; + }; + let Some((a, b)) = mesh.edges.get(index).and_then(|e| Some((*e.first()?, *e.last()?))) else { return false }; + let eval = |e: &str| app.session.doc.eval(e, ValueKind::Length).ok().filter(|v| v.is_finite()); + let style = d.extra.get("type").and_then(Value::as_str).unwrap_or("constant").to_string(); + // The spline, the end its positions start from and whether it is still the taper's. + let (spline, start, taper) = if style == "spline" { + let Kind::Fillet { radius, .. } = &d.kind else { return false }; + let knots: Vec = d.extra.get("knots").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_f64).collect(); + let exprs: Vec = + d.extra.get("radii").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + let Some(radii) = std::iter::once(radius.as_str()).chain(exprs.iter().map(String::as_str)).map(eval).collect::>>() else { + return false; + }; + let swap = d.extra.get("swap_ends").and_then(Value::as_bool).unwrap_or(false); + (solidcraft_engine::doc::FilletStyle::spline(&knots, radii), if swap { b } else { a }, false) + } else if style == "constant" && d.extra.get("fit").and_then(Value::as_str) == Some("taper") { + // The taper of this edge: its point lies on the edge's chord. + let on_edge = |p: Vec3| { + let ab = b - a; + let s = ((p - a).dot(ab) / ab.dot(ab).max(1e-300)).clamp(0.0, 1.0); + (a + ab * s).dist(p) <= 1e-6 * (1.0 + ab.len()) + }; + let Some((_, tp)) = app.preview.tapered.iter().find(|(p, _)| on_edge(*p)) else { return false }; + (tp.spline.clone(), tp.start, true) + } else { + return false; + }; + let other = if start.dist(a) <= start.dist(b) { b } else { a }; + let at = |u: f64| start + (other - start) * u; + // Into the body, between the two faces (the edge is straight between planar faces). + let Some(n) = edge_bisector(&mesh, index, at(0.5)).map(|v| -v) else { return false }; + let samples: Vec = (0..=48) + .filter_map(|k| { + let u = f64::from(k) / 48.0; + proj.to_screen(at(u) + n * spline.eval(u)) + }) + .collect(); + painter.add(egui::Shape::line(samples, Stroke::new(1.5, t.manipulator))); + let g = spline.greville(); + let tips: Vec> = g.iter().zip(&spline.radii).map(|(u, r)| proj.to_screen(at(*u) + n * *r)).collect(); + painter.add(egui::Shape::dashed_line(&tips.iter().flatten().copied().collect::>(), Stroke::new(1.0, t.manipulator), 4.0, 3.0)); + let half_height = app.cam.half_height(); + let last = g.len().saturating_sub(1); + let mut changed = None; + for (k, (u, ts)) in g.iter().zip(&tips).enumerate() { + let Some(ts) = *ts else { continue }; + crate::scenario::publish_handle(&format!("fillet_r{k}"), ts); + let r = ui.interact(egui::Rect::from_center_size(ts, vec2(16.0, 16.0)), egui::Id::new(("sc_fillet_r", k)), egui::Sense::drag()); + let hot = r.hovered() || r.dragged(); + let col = if hot { t.accent } else { t.manipulator }; + if let Some(fs) = proj.to_screen(at(*u)) { + painter.line_segment([fs, ts], Stroke::new(1.0, col)); + } + painter.circle(ts, if hot { 6.5 } else { 5.0 }, t.overlay, Stroke::new(2.0, col)); + if hot { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + if r.dragged() + && let Some(p) = r.interact_pointer_pos() + && let Some(v) = along(proj, p, at(*u), n) + { + let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command); + let step = snap_step(half_height); + let v = if free { v } else { (v / step).round() * step }; + // Zero only at an end, where the round closes to a point. + let floor = if k == 0 || k == last { + 0.0 + } else if free { + 1e-3 + } else { + step + }; + changed = Some((k, v.max(floor))); + } + } + if let Some((k, v)) = changed { + let mut radii = spline.radii.clone(); + if let Some(r) = radii.get_mut(k) { + *r = v; + } + if taper { + // The taper's spline becomes the fillet's own. + d.extra.insert("type".into(), json!("spline")); + d.extra.remove("fit"); + d.extra.insert("knots".into(), json!(spline.knots)); + d.extra.insert("swap_ends".into(), json!(start.dist(b) < start.dist(a))); + d.extra.insert("radii".into(), json!(radii.iter().skip(1).map(|r| format_len(*r)).collect::>())); + if let Kind::Fillet { radius, .. } = &mut d.kind { + *radius = format_len(radii.first().copied().unwrap_or(0.0)); + } + } else if k == 0 { + if let Kind::Fillet { radius, .. } = &mut d.kind { + *radius = format_len(v); + } + } else if let Some(Value::Array(rs)) = d.extra.get_mut("radii") + && let Some(slot) = rs.get_mut(k - 1) + { + *slot = json!(format_len(v)); + } + } + true +} + /// The measured distance: a line between the closest points with its length. fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_json::Value) { let t = Tokens::get(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 02221193..fa9953d1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1700,7 +1700,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { &mut d.extra, "fi_type", "type", - &[("Constant", "constant"), ("Chord Length", "chord"), ("Variable", "variable")], + &[("Constant", "constant"), ("Chord Length", "chord"), ("Variable", "variable"), ("Spline", "spline")], ); ui.end_row(); } @@ -1709,7 +1709,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if !*chamfer { match rtype { 1 => "Chord Length", - 2 => "Start Radius", + 2 | 3 => "Start Radius", _ => "Radius", } } else if *ctype == 0 { @@ -1804,6 +1804,21 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.end_row(); } } + if !*chamfer && rtype == 3 { + // The radius along the edge as a B-spline: its control radii from the + // start end, also dragged by their handles on the edge. + row_label(ui, "Swap Ends"); + extra_check(ui, &mut d.extra, "swap_ends"); + ui.end_row(); + let mut radii = spline_radii(&mut d.extra, radius); + let n = radii.len(); + for (k, r) in radii.iter_mut().enumerate() { + row_label(ui, if k + 1 == n { "End Radius" } else { "Control Radius" }); + enter |= field(ui, r); + ui.end_row(); + } + d.extra.insert("radii".into(), json!(radii)); + } row_label(ui, "Tangent Chain"); ui.checkbox(chain, ""); ui.end_row(); @@ -2391,6 +2406,29 @@ fn extra_field(ui: &mut egui::Ui, extra: &mut serde_json::Map, ke enter } +/// A spline fillet's control radii after the start radius, as expressions. A dialog without a +/// spline yet gets a cubic one of two spans, all its control radii `radius`. +pub(crate) fn spline_radii(extra: &mut serde_json::Map, radius: &str) -> Vec { + let radii: Vec = extra + .get("radii") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|r| match r { + Value::String(x) => Some(x.clone()), + Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .collect(); + let knots = extra.get("knots").and_then(Value::as_array).map_or(0, Vec::len); + // All control radii (the start radius and these) plus the degree (1…3) plus one. + if !radii.is_empty() && (radii.len() + 3..=radii.len() + 5).contains(&knots) { + return radii; + } + extra.insert("knots".into(), json!([0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0])); + vec![radius.to_string(); 4] +} + /// A variable fillet's middle radii as (position, radius expression). fn mid_points(extra: &serde_json::Map) -> Vec<(f64, String)> { extra @@ -2770,7 +2808,8 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S ("solid.chamfer", p) } else { let mut p = json!({"edges": edges, "radius": radius}); - if d.extra.get("type").and_then(Value::as_str) == Some("variable") { + let style = d.extra.get("type").and_then(Value::as_str); + if matches!(style, Some("variable" | "spline")) { // The start radius goes at one end of the first edge (Swap Ends: the other). let ends = sels(d, 0).iter().find_map(|x| match x { Sel::Edge { body, index, .. } => { @@ -2783,7 +2822,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S p["start"] = pt(if swap { b } else { a }); } let mids = mid_points(&d.extra); - if !mids.is_empty() { + if style == Some("variable") && !mids.is_empty() { p["mid"] = json!(mids.iter().map(|(t, r)| json!([t, r])).collect::>()); if d.extra.get("smooth").and_then(Value::as_bool).unwrap_or(false) { p["smooth"] = json!(true); @@ -3585,11 +3624,21 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { d.extra.insert("type".into(), json!("chord")); } - FilletStyle::Variable { radius2, start, mid, smooth } => { - d.extra.insert("type".into(), json!("variable")); - d.extra.insert("radius2".into(), json!(radius2)); - d.extra.insert("mid".into(), json!(mid.iter().map(|(t, r)| json!([t, r])).collect::>())); - d.extra.insert("smooth".into(), json!(smooth)); + FilletStyle::Variable { start, .. } | FilletStyle::Spline { start, .. } => { + match style { + FilletStyle::Variable { radius2, mid, smooth, .. } => { + d.extra.insert("type".into(), json!("variable")); + d.extra.insert("radius2".into(), json!(radius2)); + d.extra.insert("mid".into(), json!(mid.iter().map(|(t, r)| json!([t, r])).collect::>())); + d.extra.insert("smooth".into(), json!(smooth)); + } + FilletStyle::Spline { knots, radii, .. } => { + d.extra.insert("type".into(), json!("spline")); + d.extra.insert("knots".into(), json!(knots)); + d.extra.insert("radii".into(), json!(radii)); + } + FilletStyle::Constant | FilletStyle::Chord => {} + } // The start was the first edge's last point: ends swapped. let first_end = edges.first().and_then(|p| edge_sel(s, *p)).and_then(|x| match x { Sel::Edge { body, index, .. } => { diff --git a/crates/ui-egui/src/fillet_spline_handle_tests.rs b/crates/ui-egui/src/fillet_spline_handle_tests.rs new file mode 100644 index 00000000..eb0e6e49 --- /dev/null +++ b/crates/ui-egui/src/fillet_spline_handle_tests.rs @@ -0,0 +1,76 @@ +use crate::scenario::Harness; +use serde_json::{Value, json}; +use solidcraft_engine::doc::{FeatureKind, FilletStyle, kernel}; + +/// A plate whose top narrows from 20 to 2 mm along 40 mm, its top front edge rounded at 3 mm +/// with Taper to Fit. +fn fixture() -> Harness { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.line", json!({"points": [[0, 0], [40, 0], [40, 2], [0, 20]], "closed": true})).unwrap(); + h.app.run("solid.extrude", json!({"distance": 15, "body_name": "Plate"})).unwrap(); + h.app.run("solid.fillet", json!({"edges": [[20, 0, 15]], "radius": 3, "fit": "taper", "name": "Round"})).unwrap(); + h.frames(4); + h +} + +/// Open the fillet for editing and wait for its preview. +fn open(h: &mut Harness) { + h.step(&json!({"call": "ui.view", "params": {"view": "iso"}})).unwrap(); + h.step(&json!({"call": "ui.view", "params": {"view": "fit"}})).unwrap(); + h.step(&json!({"call": "ui.editFeature", "params": {"feature": "Round"}})).unwrap(); + let began = std::time::Instant::now(); + while (h.app.preview.busy || h.app.preview.tapered.is_empty()) && began.elapsed() < std::time::Duration::from_secs(60) { + h.frame(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + h.frames(4); +} + +fn volume(h: &Harness) -> f64 { + kernel::measure(&h.app.session.world_state().body("Plate").unwrap().body).unwrap().volume +} + +#[test] +fn dragging_a_taper_handle_makes_the_fillet_a_spline() { + let mut h = fixture(); + let tapered = volume(&h); + open(&mut h); + let (_, taper) = h.app.preview.tapered.first().cloned().expect("the preview's taper"); + let law = + if taper.start.dist(solidcraft_engine::geom::Vec3::new(0.0, 0.0, 15.0)) < 1e-9 { taper.spline.clone() } else { taper.spline.reversed() }; + // A handle per control radius, each that radius into the body from the edge. + let into = solidcraft_engine::geom::Vec3::new(0.0, 1.0, -1.0) * std::f64::consts::FRAC_1_SQRT_2; + let at = |u: f64, r: f64| { + let p = solidcraft_engine::geom::Vec3::new(40.0 * u, 0.0, 15.0) + into * r; + json!([p.x, p.y, p.z]) + }; + let g = law.greville(); + assert_eq!(g.len(), law.radii.len()); + for (k, (u, r)) in g.iter().zip(&law.radii).enumerate() { + let k = if taper.spline == law { k } else { g.len() - 1 - k }; + let shown = h.at(&json!({"handle": format!("fillet_r{k}")})).unwrap(); + let want = h.at(&json!({"world": at(*u, *r)})).unwrap(); + assert!((shown[0] - want[0]).hypot(shown[1] - want[1]) < 1.0, "handle {k}: {shown:?} vs {want:?}"); + } + // The middle control radius pulled out to 4 mm (free drag): the fillet keeps the taper's + // spline as its own with that radius changed. + let mid = g.len() / 2; + let k = if taper.spline == law { mid } else { g.len() - 1 - mid }; + h.step(&json!({"drag": [{"handle": format!("fillet_r{k}")}, {"world": at(g[mid], 4.0)}], "ctrl": true})).unwrap(); + let d = h.app.dialog.as_ref().unwrap(); + assert_eq!(d.extra.get("type").and_then(Value::as_str), Some("spline")); + assert!(d.extra.get("fit").is_none()); + h.step(&json!({"widget": "OK"})).unwrap(); + let FeatureKind::Fillet { style: FilletStyle::Spline { knots, radii, .. }, radius, .. } = &h.app.session.doc.find_feature("Round").unwrap().kind + else { + panic!("a spline fillet") + }; + assert_eq!(knots, &taper.spline.knots); + let values: Vec = + std::iter::once(radius).chain(radii).map(|e| h.app.session.doc.eval(e, solidcraft_engine::doc::expr::Kind::Length).unwrap()).collect(); + let moved: Vec = (0..values.len()).filter(|i| (values[*i] - taper.spline.radii[*i]).abs() > 1e-3).collect(); + assert_eq!(moved, [k], "{values:?} vs {:?}", taper.spline.radii); + assert!((values[k] - 4.0).abs() < 0.05, "{values:?}"); + assert!(volume(&h) < tapered, "{} vs {tapered}", volume(&h)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7173cb9c..f7697d8b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -70,6 +70,8 @@ mod extrude_edit_tests; mod file_load; #[cfg(test)] mod file_load_ui_tests; +#[cfg(test)] +mod fillet_spline_handle_tests; pub mod palette; pub mod params_dialog; pub mod prefs; diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index e14c355a..6d79dfe2 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -44,6 +44,8 @@ pub struct Built { /// The smallest and largest radius a fillet used when it reduced one too large for its edges /// (`fit: reduce`). pub applied: Option<(f64, f64)>, + /// The radius spline of each edge a fillet tapered (`fit: taper`), with a point on the edge. + pub tapered: Vec<(Vec3, solidcraft_engine::kernel::Taper)>, } type Job = Result; @@ -71,6 +73,8 @@ pub struct PreviewState { started: f64, /// The smallest and largest radius the previewed fillet used when it reduced the one asked for. pub applied: Option<(f64, f64)>, + /// The radius splines the previewed fillet tapered its edges with. + pub tapered: Vec<(Vec3, solidcraft_engine::kernel::Taper)>, } impl PreviewState { @@ -79,6 +83,7 @@ impl PreviewState { self.shown_key = None; self.error = None; self.applied = None; + self.tapered.clear(); self.busy = false; if self.active || !self.replaced.is_empty() { self.active = false; @@ -105,11 +110,13 @@ impl PreviewState { self.active = true; self.error = b.error; self.applied = b.applied; + self.tapered = b.tapered; self.set_scene(b.scene); } Err(e) => { self.error = Some(e); self.applied = None; + self.tapered.clear(); } } } @@ -232,7 +239,9 @@ pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job { b.bounds = state_bounds(&p.after); // The radius a previewed fillet used when it reduced the one asked for (a new // fillet or an edited one: the feature the replies name). - b.applied = p.replies.iter().filter_map(|r| r.get("feature").and_then(Value::as_u64)).find_map(|id| p.after.fitted_range(id)); + let features = || p.replies.iter().filter_map(|r| r.get("feature").and_then(Value::as_u64)); + b.applied = features().find_map(|id| p.after.fitted_range(id)); + b.tapered = features().find_map(|id| p.after.tapered(id)).map(<[_]>::to_vec).unwrap_or_default(); Ok(b) } } @@ -269,7 +278,7 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B } } } - Built { scene: sc, replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY, applied: None } + Built { scene: sc, replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() } } /// The same commands making a new body instead of joining, cutting or intersecting, and the @@ -379,7 +388,7 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built { replaced.push(b.name.clone()); } } - Built { scene: sc, replaced, error: None, bounds: Aabb3::EMPTY, applied: None } + Built { scene: sc, replaced, error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() } } /// Which of the `nf` faces of `m` are new surface, i.e. not on the old mesh? Each is decided by