feat: add and remove fillet spline points; handles on every edge (#855)
Double-click a fillet's radius curve to add a control point, or a handle to remove one; a spline fillet shows handles on all its edges.
This commit is contained in:
1 parent
753b0a2eab
commit
b69cd2d0b7
3 files changed
+347
-83
No files matched your search
@@ -118,6 +118,47 @@ impl RadiusSpline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same radius with one more control radius: the knot `u` inserted (Boehm), which leaves
|
||||||
|
/// the curve as it was. None for `u` at or beyond an end, on an existing knot, or a spline
|
||||||
|
/// already at 100 control radii.
|
||||||
|
pub fn insert(&self, u: f64) -> Option<RadiusSpline> {
|
||||||
|
let (n, p) = (self.radii.len(), self.degree);
|
||||||
|
if !(u > 1e-6 && u < 1.0 - 1e-6) || n >= 100 || self.knots.iter().any(|k| (k - u).abs() < 1e-6) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// The span holding u: knots[k] <= u < knots[k + 1], p <= k < n.
|
||||||
|
let k = (p..n).rev().find(|i| self.knots.get(*i).is_some_and(|x| *x <= u))?;
|
||||||
|
let mut radii = Vec::with_capacity(n + 1);
|
||||||
|
radii.extend_from_slice(self.radii.get(..=k - p)?);
|
||||||
|
for i in k - p + 1..=k {
|
||||||
|
let (lo, hi) = (*self.knots.get(i)?, *self.knots.get(i + p)?);
|
||||||
|
let a = (u - lo) / (hi - lo);
|
||||||
|
radii.push((1.0 - a) * self.radii.get(i - 1)? + a * self.radii.get(i)?);
|
||||||
|
}
|
||||||
|
radii.extend_from_slice(self.radii.get(k..)?);
|
||||||
|
let mut knots = self.knots.clone();
|
||||||
|
knots.insert(k + 1, u);
|
||||||
|
Some(RadiusSpline { degree: self.degree, knots, radii })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same spline without control radius `j` and the inner knot nearest its position: the
|
||||||
|
/// curve changes around it, the ends stay. None for an end radius or a spline with no inner
|
||||||
|
/// knot to remove.
|
||||||
|
pub fn remove(&self, j: usize) -> Option<RadiusSpline> {
|
||||||
|
let (n, p) = (self.radii.len(), self.degree);
|
||||||
|
if j == 0 || j + 1 >= n || n <= p + 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let at = *self.greville().get(j)?;
|
||||||
|
let inner = p + 1..n;
|
||||||
|
let drop = inner.min_by(|a, b| (self.knots[*a] - at).abs().total_cmp(&(self.knots[*b] - at).abs()))?;
|
||||||
|
let mut knots = self.knots.clone();
|
||||||
|
knots.remove(drop);
|
||||||
|
let mut radii = self.radii.clone();
|
||||||
|
radii.remove(j);
|
||||||
|
Some(RadiusSpline { degree: self.degree, knots, radii })
|
||||||
|
}
|
||||||
|
|
||||||
/// Refused unless it is a clamped B-spline from 0 to 1 of degree 1 to 3 with 2 to 100 control
|
/// Refused unless it is a clamped B-spline from 0 to 1 of degree 1 to 3 with 2 to 100 control
|
||||||
/// radii, positive along the edge and zero at most at one end.
|
/// radii, positive along the edge and zero at most at one end.
|
||||||
fn check(&self) -> Result<()> {
|
fn check(&self) -> Result<()> {
|
||||||
@@ -571,6 +612,34 @@ mod tests {
|
|||||||
(a - b).abs() / b.abs().max(1e-9)
|
(a - b).abs() / b.abs().max(1e-9)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inserting a knot keeps the curve and adds one control radius; removing a control radius
|
||||||
|
/// takes out one inner knot and keeps the ends.
|
||||||
|
#[test]
|
||||||
|
fn radius_spline_knot_insertion_and_removal() {
|
||||||
|
let law =
|
||||||
|
RadiusSpline { degree: 3, knots: vec![0.0, 0.0, 0.0, 0.0, 0.25, 0.6, 1.0, 1.0, 1.0, 1.0], radii: vec![2.0, 5.0, 1.5, 4.0, 1.0, 3.0] };
|
||||||
|
for u in [0.1, 0.25 + 1e-3, 0.4, 0.9] {
|
||||||
|
let more = law.insert(u).unwrap();
|
||||||
|
assert_eq!((more.radii.len(), more.knots.len()), (7, 11));
|
||||||
|
assert!(more.check().is_ok());
|
||||||
|
let worst = (0..=400).map(|i| f64::from(i) / 400.0).map(|t| (more.eval(t) - law.eval(t)).abs()).fold(0.0, f64::max);
|
||||||
|
assert!(worst < 1e-12, "insert {u}: off by {worst}");
|
||||||
|
// Removing the control radius just added drops a knot again.
|
||||||
|
let j = more.greville().iter().enumerate().min_by(|a, b| (a.1 - u).abs().total_cmp(&(b.1 - u).abs())).unwrap().0;
|
||||||
|
let less = more.remove(j).unwrap();
|
||||||
|
assert_eq!((less.radii.len(), less.knots.len()), (6, 10));
|
||||||
|
assert!(less.check().is_ok());
|
||||||
|
}
|
||||||
|
// Not at an end, on a knot, or outside.
|
||||||
|
assert!(law.insert(0.0).is_none() && law.insert(0.6).is_none() && law.insert(1.2).is_none());
|
||||||
|
// Ends stay, and a single cubic span has no inner knot to give up.
|
||||||
|
assert!(law.remove(0).is_none() && law.remove(5).is_none());
|
||||||
|
let bare = RadiusSpline { degree: 3, knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], radii: vec![2.0, 3.0, 3.0, 2.0] };
|
||||||
|
assert!(bare.remove(1).is_none());
|
||||||
|
let less = law.remove(2).unwrap();
|
||||||
|
assert_eq!((less.eval(0.0), less.eval(1.0)), (2.0, 3.0));
|
||||||
|
}
|
||||||
|
|
||||||
/// The material a variable fillet removes from a right-angled edge of length L, radius
|
/// The material a variable fillet removes from a right-angled edge of length L, radius
|
||||||
/// running r0 → r1: ∫ (1 − π/4) r(t)² dt = (1 − π/4) L (r0² + r0 r1 + r1²) / 3.
|
/// running r0 → r1: ∫ (1 − π/4) r(t)² dt = (1 − π/4) L (r0² + r0 r1 + r1²) / 3.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+167
-83
@@ -471,29 +471,45 @@ fn drag_arrow(
|
|||||||
Some(ts)
|
Some(ts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fillet's radius spline on its first edge (a spline fillet, or the spline a taper gave it):
|
/// A fillet's radius spline on each of its edges (a spline fillet, or the splines a taper gave
|
||||||
/// the radius curve drawn into the body from the edge, and a handle per control radius at its
|
/// them): the radius curve drawn into the body from the edge, and a handle per control radius at
|
||||||
/// Greville position, that far from the edge. Dragging a handle sets its control radius (snapped
|
/// its Greville position, that far from the edge. Dragging a handle sets its control radius
|
||||||
/// unless Alt/Ctrl); the first drag on a taper makes the fillet a spline fillet starting from the
|
/// (snapped unless Alt/Ctrl); double-clicking the curve adds a control radius there without
|
||||||
/// taper's spline. Whether the handles are shown.
|
/// changing it, double-clicking a handle between the ends removes it. A spline fillet's edges
|
||||||
|
/// share one spline, so every edge's handles edit it. A taper's handles show on a single edge
|
||||||
|
/// only: the first edit makes the fillet a spline fillet starting from that edge's taper, which
|
||||||
|
/// would put the same radius on every other edge. Whether the handles are shown.
|
||||||
fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog) -> bool {
|
fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog) -> bool {
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
use solidcraft_engine::doc::kernel::RadiusSpline;
|
||||||
|
enum Edit {
|
||||||
|
Set(usize, f64),
|
||||||
|
Insert(f64),
|
||||||
|
Remove(usize),
|
||||||
|
}
|
||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
let Kind::Fillet { chamfer: false, .. } = d.kind else { return false };
|
let Kind::Fillet { chamfer: false, .. } = d.kind else { return false };
|
||||||
let st = app.session.world_state();
|
let st = app.session.world_state();
|
||||||
let Some((mesh, index)) = d.inputs.first().and_then(|i| {
|
let edges: Vec<(std::sync::Arc<Mesh>, usize, Vec3, Vec3)> = d
|
||||||
i.items.iter().find_map(|x| match x {
|
.inputs
|
||||||
Sel::Edge { body, index, .. } => Some((st.body(body)?.mesh(), *index)),
|
.first()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|i| &i.items)
|
||||||
|
.filter_map(|x| match x {
|
||||||
|
Sel::Edge { body, index, .. } => {
|
||||||
|
let m = st.body(body)?.mesh();
|
||||||
|
let (a, b) = m.edges.get(*index).and_then(|e| Some((*e.first()?, *e.last()?)))?;
|
||||||
|
Some((m, *index, a, b))
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
}) else {
|
.collect();
|
||||||
return false;
|
let Some(&(_, _, a0, b0)) = edges.first() 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 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();
|
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.
|
// Each edge's spline with the end its positions start from; the spline the handles edit,
|
||||||
let (spline, start, taper) = if style == "spline" {
|
// whether it starts at the first edge's far end, and whether it is still the taper's.
|
||||||
|
let (laws, base, swap, taper): (Vec<Option<(RadiusSpline, Vec3)>>, Option<RadiusSpline>, bool, bool) = if style == "spline" {
|
||||||
let Kind::Fillet { radius, .. } = &d.kind else { return false };
|
let Kind::Fillet { radius, .. } = &d.kind else { return false };
|
||||||
let knots: Vec<f64> = d.extra.get("knots").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_f64).collect();
|
let knots: Vec<f64> = d.extra.get("knots").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_f64).collect();
|
||||||
let exprs: Vec<String> =
|
let exprs: Vec<String> =
|
||||||
@@ -501,91 +517,159 @@ fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Pain
|
|||||||
let Some(radii) = std::iter::once(radius.as_str()).chain(exprs.iter().map(String::as_str)).map(eval).collect::<Option<Vec<f64>>>() else {
|
let Some(radii) = std::iter::once(radius.as_str()).chain(exprs.iter().map(String::as_str)).map(eval).collect::<Option<Vec<f64>>>() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
let spline = solidcraft_engine::doc::FilletStyle::spline(&knots, radii);
|
||||||
let swap = d.extra.get("swap_ends").and_then(Value::as_bool).unwrap_or(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)
|
let start = if swap { b0 } else { a0 };
|
||||||
|
// Each edge runs from its end nearest the start, as the fillet builds it.
|
||||||
|
let laws = edges.iter().map(|(_, _, a, b)| Some((spline.clone(), if start.dist(*a) <= start.dist(*b) { *a } else { *b }))).collect();
|
||||||
|
(laws, Some(spline), swap, false)
|
||||||
} else if style == "constant" && d.extra.get("fit").and_then(Value::as_str) == Some("taper") {
|
} 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.
|
// The taper of an edge: its point lies on the edge's chord. An edge that fits has none.
|
||||||
let on_edge = |p: Vec3| {
|
let on_edge = |a: Vec3, b: Vec3, p: Vec3| {
|
||||||
let ab = b - a;
|
let ab = b - a;
|
||||||
let s = ((p - a).dot(ab) / ab.dot(ab).max(1e-300)).clamp(0.0, 1.0);
|
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())
|
(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 };
|
let laws: Vec<Option<(RadiusSpline, Vec3)>> = edges
|
||||||
(tp.spline.clone(), tp.start, true)
|
.iter()
|
||||||
|
.map(|(_, _, a, b)| app.preview.tapered.iter().find(|(p, _)| on_edge(*a, *b, *p)).map(|(_, tp)| (tp.spline.clone(), tp.start)))
|
||||||
|
.collect();
|
||||||
|
let base = laws.first().cloned().flatten().filter(|_| edges.len() == 1);
|
||||||
|
let swap = base.as_ref().is_some_and(|(_, s)| s.dist(b0) < s.dist(a0));
|
||||||
|
(laws, base.map(|(l, _)| l), swap, true)
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let other = if start.dist(a) <= start.dist(b) { b } else { a };
|
// Each drawn edge: its spline, the ends its positions run between, the direction into the
|
||||||
let at = |u: f64| start + (other - start) * u;
|
// body, and the curve on screen with its positions.
|
||||||
// Into the body, between the two faces (the edge is straight between planar faces).
|
struct Drawn<'a> {
|
||||||
let Some(n) = edge_bisector(&mesh, index, at(0.5)).map(|v| -v) else { return false };
|
spline: &'a RadiusSpline,
|
||||||
let samples: Vec<Pos2> = (0..=48)
|
start: Vec3,
|
||||||
.filter_map(|k| {
|
other: Vec3,
|
||||||
let u = f64::from(k) / 48.0;
|
n: Vec3,
|
||||||
proj.to_screen(at(u) + n * spline.eval(u))
|
curve: Vec<(f64, Pos2)>,
|
||||||
|
}
|
||||||
|
let drawn: Vec<Drawn> = edges
|
||||||
|
.iter()
|
||||||
|
.zip(&laws)
|
||||||
|
.filter_map(|((mesh, index, a, b), law)| {
|
||||||
|
let (spline, start) = law.as_ref()?;
|
||||||
|
let other = if start.dist(*a) <= start.dist(*b) { *b } else { *a };
|
||||||
|
let mid = *start + (other - *start) * 0.5;
|
||||||
|
// Into the body, between the two faces (the edge is straight between planar faces).
|
||||||
|
let n = -edge_bisector(mesh, *index, mid)?;
|
||||||
|
let curve = (0..=48)
|
||||||
|
.filter_map(|k| {
|
||||||
|
let u = f64::from(k) / 48.0;
|
||||||
|
Some((u, proj.to_screen(*start + (other - *start) * u + n * spline.eval(u))?))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Some(Drawn { spline, start: *start, other, n, curve })
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
painter.add(egui::Shape::line(samples, Stroke::new(1.5, t.manipulator)));
|
for e in &drawn {
|
||||||
let g = spline.greville();
|
painter.add(egui::Shape::line(e.curve.iter().map(|c| c.1).collect(), Stroke::new(1.5, t.manipulator)));
|
||||||
let tips: Vec<Option<Pos2>> = 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::<Vec<_>>(), Stroke::new(1.0, t.manipulator), 4.0, 3.0));
|
let Some(base) = base.filter(|_| !drawn.is_empty()) else { return false };
|
||||||
let half_height = app.cam.half_height();
|
let mut edit = None;
|
||||||
let last = g.len().saturating_sub(1);
|
// Double-clicking the curve adds a control radius at the nearest position along it. Under
|
||||||
let mut changed = None;
|
// the handles, which are registered after it and so take the pointer first.
|
||||||
for (k, (u, ts)) in g.iter().zip(&tips).enumerate() {
|
let near = ui.input(|i| i.pointer.hover_pos()).and_then(|p| {
|
||||||
let Some(ts) = *ts else { continue };
|
drawn
|
||||||
crate::scenario::publish_handle(&format!("fillet_r{k}"), ts);
|
.iter()
|
||||||
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());
|
.flat_map(|e| e.curve.windows(2))
|
||||||
let hot = r.hovered() || r.dragged();
|
.map(|w| {
|
||||||
let col = if hot { t.accent } else { t.manipulator };
|
let ((u0, p0), (u1, p1)) = (w[0], w[1]);
|
||||||
if let Some(fs) = proj.to_screen(at(*u)) {
|
let seg = p1 - p0;
|
||||||
painter.line_segment([fs, ts], Stroke::new(1.0, col));
|
let s = ((p - p0).dot(seg) / seg.length_sq().max(1e-6)).clamp(0.0, 1.0);
|
||||||
}
|
(p0.lerp(p1, s).distance(p), u0 + (u1 - u0) * f64::from(s))
|
||||||
painter.circle(ts, if hot { 6.5 } else { 5.0 }, t.overlay, Stroke::new(2.0, col));
|
})
|
||||||
if hot {
|
.min_by(|x, y| x.0.total_cmp(&y.0))
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
.filter(|(dist, _)| *dist < 6.0)
|
||||||
}
|
.map(|(_, u)| (p, u))
|
||||||
if r.dragged()
|
});
|
||||||
&& let Some(p) = r.interact_pointer_pos()
|
if let Some((p, u)) = near {
|
||||||
&& let Some(v) = along(proj, p, at(*u), n)
|
crate::scenario::publish_handle("fillet_curve", p);
|
||||||
{
|
if ui.interact(egui::Rect::from_center_size(p, vec2(12.0, 12.0)), egui::Id::new("sc_fillet_curve"), egui::Sense::click()).double_clicked() {
|
||||||
let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command);
|
edit = Some(Edit::Insert(u));
|
||||||
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 half_height = app.cam.half_height();
|
||||||
let mut radii = spline.radii.clone();
|
let last = base.radii.len().saturating_sub(1);
|
||||||
if let Some(r) = radii.get_mut(k) {
|
for (i, e) in drawn.iter().enumerate() {
|
||||||
*r = v;
|
let at = |u: f64| e.start + (e.other - e.start) * u;
|
||||||
|
let g = e.spline.greville();
|
||||||
|
let tips: Vec<Option<Pos2>> = g.iter().zip(&e.spline.radii).map(|(u, r)| proj.to_screen(at(*u) + e.n * *r)).collect();
|
||||||
|
painter.add(egui::Shape::dashed_line(&tips.iter().flatten().copied().collect::<Vec<_>>(), Stroke::new(1.0, t.manipulator), 4.0, 3.0));
|
||||||
|
for (k, (u, ts)) in g.iter().zip(&tips).enumerate() {
|
||||||
|
let Some(ts) = *ts else { continue };
|
||||||
|
crate::scenario::publish_handle(&if i == 0 { format!("fillet_r{k}") } else { format!("fillet_e{i}_r{k}") }, ts);
|
||||||
|
let r =
|
||||||
|
ui.interact(egui::Rect::from_center_size(ts, vec2(16.0, 16.0)), egui::Id::new(("sc_fillet_r", i, k)), egui::Sense::click_and_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.double_clicked() {
|
||||||
|
edit = Some(Edit::Remove(k));
|
||||||
|
} else if r.dragged()
|
||||||
|
&& let Some(p) = r.interact_pointer_pos()
|
||||||
|
&& let Some(v) = along(proj, p, at(*u), e.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
|
||||||
|
};
|
||||||
|
edit = Some(Edit::Set(k, v.max(floor)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if taper {
|
}
|
||||||
// The taper's spline becomes the fillet's own.
|
// A dragged radius on a spline fillet changes its own expression; anything else writes the
|
||||||
d.extra.insert("type".into(), json!("spline"));
|
// whole spline (inserting or removing a knot moves its neighbours too).
|
||||||
d.extra.remove("fit");
|
let whole = match edit {
|
||||||
d.extra.insert("knots".into(), json!(spline.knots));
|
Some(Edit::Set(k, v)) if !taper => {
|
||||||
d.extra.insert("swap_ends".into(), json!(start.dist(b) < start.dist(a)));
|
if k == 0 {
|
||||||
d.extra.insert("radii".into(), json!(radii.iter().skip(1).map(|r| format_len(*r)).collect::<Vec<_>>()));
|
if let Kind::Fillet { radius, .. } = &mut d.kind {
|
||||||
if let Kind::Fillet { radius, .. } = &mut d.kind {
|
*radius = format_len(v);
|
||||||
*radius = format_len(radii.first().copied().unwrap_or(0.0));
|
}
|
||||||
|
} 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));
|
||||||
}
|
}
|
||||||
} else if k == 0 {
|
None
|
||||||
if let Kind::Fillet { radius, .. } = &mut d.kind {
|
}
|
||||||
*radius = format_len(v);
|
Some(Edit::Set(k, v)) => {
|
||||||
|
let mut law = base.clone();
|
||||||
|
if let Some(r) = law.radii.get_mut(k) {
|
||||||
|
*r = v;
|
||||||
}
|
}
|
||||||
} else if let Some(Value::Array(rs)) = d.extra.get_mut("radii")
|
Some(law)
|
||||||
&& let Some(slot) = rs.get_mut(k - 1)
|
}
|
||||||
{
|
Some(Edit::Insert(u)) => base.insert(u),
|
||||||
*slot = json!(format_len(v));
|
Some(Edit::Remove(k)) => base.remove(k),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
if let Some(law) = whole {
|
||||||
|
d.extra.insert("type".into(), json!("spline"));
|
||||||
|
d.extra.remove("fit");
|
||||||
|
d.extra.insert("knots".into(), json!(law.knots));
|
||||||
|
d.extra.insert("swap_ends".into(), json!(swap));
|
||||||
|
d.extra.insert("radii".into(), json!(law.radii.iter().skip(1).map(|r| format_len(*r)).collect::<Vec<_>>()));
|
||||||
|
if let Kind::Fillet { radius, .. } = &mut d.kind {
|
||||||
|
*radius = format_len(law.radii.first().copied().unwrap_or(0.0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -74,3 +74,114 @@ fn dragging_a_taper_handle_makes_the_fillet_a_spline() {
|
|||||||
assert!((values[k] - 4.0).abs() < 0.05, "{values:?}");
|
assert!((values[k] - 4.0).abs() < 0.05, "{values:?}");
|
||||||
assert!(volume(&h) < tapered, "{} vs {tapered}", volume(&h));
|
assert!(volume(&h) < tapered, "{} vs {tapered}", volume(&h));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A 40x30x20 box, its two top edges along x rounded by one cubic radius spline of two spans
|
||||||
|
/// from x = 0: control radii 2, 3, 4, 3, 2.
|
||||||
|
fn spline_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, 30], [0, 30]], "closed": true})).unwrap();
|
||||||
|
h.app.run("solid.extrude", json!({"distance": 20, "body_name": "Box"})).unwrap();
|
||||||
|
h.app
|
||||||
|
.run(
|
||||||
|
"solid.fillet",
|
||||||
|
json!({"edges": [[20, 0, 20], [20, 30, 20]], "radius": 2, "type": "spline", "start": [0, 0, 20],
|
||||||
|
"knots": [0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0], "radii": [3, 4, 3, 2], "name": "Round"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
h.frames(4);
|
||||||
|
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 && began.elapsed() < std::time::Duration::from_secs(60) {
|
||||||
|
h.frame();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
h.frames(4);
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dialog's spline: knots and control radii.
|
||||||
|
fn dialog_spline(h: &Harness) -> kernel::RadiusSpline {
|
||||||
|
let d = h.app.dialog.as_ref().unwrap();
|
||||||
|
let crate::dialogs::Kind::Fillet { radius, .. } = &d.kind else { panic!("a fillet dialog") };
|
||||||
|
let knots: Vec<f64> = d.extra["knots"].as_array().unwrap().iter().filter_map(Value::as_f64).collect();
|
||||||
|
let rest: Vec<String> = d.extra["radii"].as_array().unwrap().iter().map(|r| r.as_str().map_or_else(|| r.to_string(), str::to_owned)).collect();
|
||||||
|
let values =
|
||||||
|
std::iter::once(radius).chain(&rest).map(|e| h.app.session.doc.eval(e, solidcraft_engine::doc::expr::Kind::Length).unwrap()).collect();
|
||||||
|
FilletStyle::spline(&knots, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A point `r` into the box from the top edge at y (0 or 30), x = 40 u.
|
||||||
|
fn into_box(u: f64, y: f64, r: f64) -> Value {
|
||||||
|
let side = if y == 0.0 { 1.0 } else { -1.0 };
|
||||||
|
let p = solidcraft_engine::geom::Vec3::new(40.0 * u, y + side * r * std::f64::consts::FRAC_1_SQRT_2, 20.0 - r * std::f64::consts::FRAC_1_SQRT_2);
|
||||||
|
json!({"world": [p.x, p.y, p.z]})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_edge_of_a_spline_fillet_has_handles_for_its_one_spline() {
|
||||||
|
let mut h = spline_fixture();
|
||||||
|
let law = dialog_spline(&h);
|
||||||
|
let g = law.greville();
|
||||||
|
for (k, (u, r)) in g.iter().zip(&law.radii).enumerate() {
|
||||||
|
for (name, y) in [(format!("fillet_r{k}"), 0.0), (format!("fillet_e1_r{k}"), 30.0)] {
|
||||||
|
let shown = h.at(&json!({"handle": name})).unwrap();
|
||||||
|
let want = h.at(&into_box(*u, y, *r)).unwrap();
|
||||||
|
assert!((shown[0] - want[0]).hypot(shown[1] - want[1]) < 1.0, "{name}: {shown:?} vs {want:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The back edge's middle handle pulled to 5 mm: the shared spline's middle radius.
|
||||||
|
h.step(&json!({"drag": [{"handle": "fillet_e1_r2"}, into_box(g[2], 30.0, 5.0)], "ctrl": true})).unwrap();
|
||||||
|
let moved = dialog_spline(&h);
|
||||||
|
assert_eq!(moved.knots, law.knots);
|
||||||
|
let changed: Vec<usize> = (0..law.radii.len()).filter(|i| (moved.radii[*i] - law.radii[*i]).abs() > 1e-3).collect();
|
||||||
|
assert_eq!(changed, [2], "{:?}", moved.radii);
|
||||||
|
assert!((moved.radii[2] - 5.0).abs() < 0.05, "{:?}", moved.radii);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_clicks_add_and_remove_control_radii() {
|
||||||
|
let mut h = spline_fixture();
|
||||||
|
let law = dialog_spline(&h);
|
||||||
|
let edges = h.app.dialog.as_ref().unwrap().inputs[0].items.len();
|
||||||
|
// On the curve at x = 12: one more control radius, the same curve.
|
||||||
|
h.step(&json!({"click": into_box(0.3, 0.0, law.eval(0.3)), "double": true})).unwrap();
|
||||||
|
let more = dialog_spline(&h);
|
||||||
|
assert_eq!((more.radii.len(), more.knots.len()), (law.radii.len() + 1, law.knots.len() + 1));
|
||||||
|
let worst = (0..=100).map(|i| f64::from(i) / 100.0).map(|t| (more.eval(t) - law.eval(t)).abs()).fold(0.0, f64::max);
|
||||||
|
// Control radii are kept to the dialog's precision.
|
||||||
|
assert!(worst < 1e-3, "the curve moved by {worst}");
|
||||||
|
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), edges, "the double-click picked something");
|
||||||
|
// A middle handle double-clicked: gone again. An end handle stays.
|
||||||
|
// A pause between gestures, so egui doesn't take the next two clicks for a triple click.
|
||||||
|
h.frames(30);
|
||||||
|
h.step(&json!({"click": {"handle": "fillet_r1"}, "double": true})).unwrap();
|
||||||
|
assert_eq!(dialog_spline(&h).radii.len(), law.radii.len());
|
||||||
|
h.frames(30);
|
||||||
|
h.step(&json!({"click": {"handle": "fillet_r0"}, "double": true})).unwrap();
|
||||||
|
assert_eq!(dialog_spline(&h).radii.len(), law.radii.len());
|
||||||
|
// The fillet builds with the edited spline.
|
||||||
|
h.step(&json!({"widget": "OK"})).unwrap();
|
||||||
|
let FeatureKind::Fillet { style: FilletStyle::Spline { radii, .. }, .. } = &h.app.session.doc.find_feature("Round").unwrap().kind else {
|
||||||
|
panic!("a spline fillet")
|
||||||
|
};
|
||||||
|
assert_eq!(radii.len() + 1, law.radii.len());
|
||||||
|
assert!(h.app.session.world_state().body("Box").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_taper_over_several_edges_keeps_the_radius_arrow() {
|
||||||
|
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], [20, 0, 0]], "radius": 3, "fit": "taper", "name": "Round"})).unwrap();
|
||||||
|
h.frames(4);
|
||||||
|
open(&mut h);
|
||||||
|
assert_eq!(h.app.preview.tapered.len(), 2, "both edges taper");
|
||||||
|
// One spline would replace every edge's taper, so none is offered to drag.
|
||||||
|
assert!(crate::scenario::handles("fillet_").is_empty(), "{:?}", crate::scenario::handles("fillet_"));
|
||||||
|
assert!(h.at(&json!({"handle": "arrow"})).is_ok());
|
||||||
|
}
|
||||||
Reference in new issue
Block a user