diff --git a/crates/engine/src/cmd/sketch_corner_tests.rs b/crates/engine/src/cmd/sketch_corner_tests.rs index 344e8fea..b34ea4c3 100644 --- a/crates/engine/src/cmd/sketch_corner_tests.rs +++ b/crates/engine/src/cmd/sketch_corner_tests.rs @@ -96,3 +96,90 @@ fn fillet_and_chamfer_corners() { assert!(solved(&s)); assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s)); } + +/// The ids of the constraints driven by the model parameter `name`. +fn driven_by(s: &Session, name: &str) -> Vec { + sk(s).constraints.iter().filter(|c| c.param.as_deref() == Some(name)).map(|c| c.id.clone()).collect() +} + +/// The spout of a basin: a vertical riser and a horizontal top, the top's length dimensioned +/// to the corner and the corner's height dimensioned from the origin. Filleting the corner used +/// to drop the height and leave the length on the shortened straight part, so the top came out +/// a fillet radius too long. Like Fusion, the corner now stays as a virtual sharp corner (a point +/// on both lines' extensions) and the dimensions measure to it. +#[test] +fn a_fillet_keeps_the_dimensions_of_its_corner_on_a_virtual_sharp() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, 100], [-80, 100]], "ids": ["riser", "top"]})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "riser", "mode": "vertical"})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "top", "mode": "horizontal"})); + let height = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "riser.end"], "type": "vertical", "value": 100})); + let height = height["param"].as_str().unwrap().to_string(); + let len = run(&mut s, "sketch.dimension", json!({"entities": ["top"], "value": 80})); + let len = len["param"].as_str().unwrap().to_string(); + let (height_ids, len_ids) = (driven_by(&s, &height), driven_by(&s, &len)); + let f = run(&mut s, "sketch.fillet", json!({"point": "riser.end", "radius": 20})); + assert!(solved(&s), "{f}"); + // Both dimensions are kept (same ids) and reported as moved to the virtual corner. + assert_eq!(driven_by(&s, &height), height_ids); + assert_eq!(driven_by(&s, &len), len_ids); + let corner = f["virtual_corners"][0].as_str().unwrap_or_default().to_string(); + assert!(!corner.is_empty(), "{f}"); + let moved: Vec = f["moved_to_virtual_corner"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect(); + for id in height_ids.iter().chain(&len_ids) { + assert!(moved.contains(id), "{id}: {f}"); + } + // The virtual corner sits where the corner was, and the part keeps its size. + let k = sk(&s); + let vc = k.point(k.point_index(&corner).unwrap()).unwrap(); + assert!(vc.dist(solvecraft_geom::Vec2::new(0.0, 100.0)) < 1e-6, "{vc:?}"); + let top_end = k.point(k.resolve_point("top.end").unwrap()).unwrap(); + assert!(top_end.dist(solvecraft_geom::Vec2::new(-80.0, 100.0)) < 1e-6, "{top_end:?}"); + let (lo, hi) = k.bounds().unwrap(); + assert!((hi.x - lo.x - 80.0).abs() < 1e-6 && (hi.y - lo.y - 100.0).abs() < 1e-6, "{lo:?} {hi:?}"); + // It is drawn with extension lines from both lines' ends to it. + let vcs = k.virtual_corners(); + assert_eq!(vcs.len(), 1, "{vcs:?}"); + assert_eq!(vcs[0].1.len(), 2, "{vcs:?}"); + assert!(vcs[0].1.iter().all(|(a, b)| a.dist(vc) < 1e-9 && (b.dist(vc) - 20.0).abs() < 1e-6), "{vcs:?}"); + // The dimensions still drive the part, measured to the virtual corner. + run(&mut s, "parameters.change", json!({"name": len, "expression": "90 mm"})); + run(&mut s, "parameters.change", json!({"name": height, "expression": "120 mm"})); + assert!(solved(&s)); + let (lo, hi) = sk(&s).bounds().unwrap(); + assert!((hi.x - lo.x - 90.0).abs() < 1e-6 && (hi.y - lo.y - 120.0).abs() < 1e-6, "{lo:?} {hi:?}"); + // It is kept in the design file, and undo and redo take it away and bring it back. + let id = s.active_sketch.unwrap(); + let doc = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(doc.sketch(id).unwrap(), s.doc.sketch(id).unwrap()); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert!(sk(&s).point_index(&corner).is_none()); + run(&mut s, "edit.redo", json!({})); + assert!(sk(&s).point_index(&corner).is_some()); + // A fillet without anything measuring to its corner leaves no virtual corner behind. + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[10, 10], [10, 100], [-80, 100]], "ids": ["riser", "top"]})); + let n = sk(&s).points.len(); + let f = run(&mut s, "sketch.fillet", json!({"point": "riser.end", "radius": 20})); + assert_eq!(f["virtual_corners"], json!([]), "{f}"); + // Corner gone, two tangent points and the arc's centre added. + assert_eq!(sk(&s).points.len(), n + 2); +} + +/// Lines meeting almost straight or almost folded back: a fillet or chamfer with dimensions on +/// the corner either works or refuses with an error, never a panic. +#[test] +fn a_fillet_of_a_nearly_straight_or_folded_corner_with_dimensions_does_not_panic() { + for (x, y) in [(100.0, 0.05), (100.0, -0.05), (100.0, 1e-9), (-100.0, 0.05), (-100.0, 1e-9)] { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0], [100.0 + x, y]], "ids": ["a", "b"]})); + run(&mut s, "sketch.dimension", json!({"entities": ["a"], "value": 100})); + for r in [0.01, 1.0, 50.0] { + let _ = s.execute("sketch.fillet", &json!({"point": "a.end", "radius": r})); + let _ = s.execute("sketch.chamfer.equal_distance", &json!({"point": "a.end", "distance": r})); + } + assert!(sk(&s).points.iter().all(|p| p.pos.is_finite())); + } +} diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 282f2def..c9fc562d 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -20,7 +20,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "MODIFY") .icon("sketch_fillet") .enabled(in_sketch) - .params("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr"), + .params("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`)"), CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal) .at("SKETCH", "MODIFY") .icon("sketch_chamfer") @@ -505,8 +505,71 @@ fn reattach(sk: &mut Sketch, l: usize, corner: usize, np: usize) { } } +/// What became of a rounded or cut corner's point: kept as a virtual sharp corner (its id), +/// and the constraints and dimensions that now measure to it. +#[derive(Default)] +struct CornerKept { + virtual_corner: Option, + moved: Vec, +} + +/// Keep corner point `c` of curves `ca`, `cb` (whose corner ends were just moved to `na`, `nb`) +/// when anything measures to it: a constraint or dimension on the point, the length of either +/// line, or it is the origin or still ends another curve. It stays as a virtual sharp corner, +/// held on both curves' extensions (their intersection), and the line lengths become distances +/// from the far end to it, so the part keeps its size, as in Fusion. Otherwise None: the +/// caller removes the point. +fn keep_corner(sk: &mut Sketch, c: usize, [(ca, na), (cb, nb)]: [(usize, usize); 2]) -> Result> { + let used = sk.curves.iter().any(|x| uses(x, c)); + let mut moved: Vec = sk.constraints.iter().filter(|k| k.kind.refers_to_point(c)).map(|k| k.id.clone()).collect(); + for k in sk.constraints.iter_mut() { + let ConstraintKind::Length { l, value } = k.kind else { continue }; + let new_end = if l == ca { + na + } else if l == cb { + nb + } else { + continue; + }; + let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|x| &x.kind) else { continue }; + // Same direction as the line, so the dimension keeps its side and text place. + k.kind = if *a == new_end { ConstraintKind::Distance { p: c, q: *b, value } } else { ConstraintKind::Distance { p: *a, q: c, value } }; + moved.push(k.id.clone()); + } + if moved.is_empty() && !used && c != 0 { + return Ok(None); + } + for cu in [ca, cb] { + let k = ConstraintKind::PointOnCurve { p: c, c: cu }; + if !sk.constraints.iter().any(|x| x.kind == k) { + add_c(sk, k)?; + } + } + // A point of its own now: `l1.end` names the line's new end, not the virtual corner. + let virtual_corner = if used || c == 0 { + None + } else { + let id = sk.fresh("corner"); + if let Some(pt) = sk.points.get_mut(c) { + pt.id = id.clone(); + } + Some(id) + }; + Ok(Some(CornerKept { virtual_corner, moved })) +} + /// Points at distances `da` along a and `db` along b from the corner; lines shortened to them. -fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: usize, da: f64, db: f64, cmd: &str) -> Result<(usize, usize, Vec2)> { +fn cut_corner( + sk: &mut Sketch, + la: usize, + lb: usize, + c: usize, + fa: usize, + fb: usize, + da: f64, + db: f64, + cmd: &str, +) -> Result<(usize, usize, Vec2, CornerKept)> { let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default()); if !(da > 1e-9 && db > 1e-9 && da < pc.dist(pa) - 1e-9 && db < pc.dist(pb) - 1e-9) { return Err(bad(cmd, "too large for these lines")); @@ -517,13 +580,14 @@ fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: us let tb = sk.add_point(pc + v * db, None)?; reattach(sk, la, c, ta); reattach(sk, lb, c, tb); - let used = sk.curves.iter().any(|x| uses(x, c)); - if !used && c != 0 { + let kept = keep_corner(sk, c, [(la, ta), (lb, tb)])?; + let removed = kept.is_none(); + if removed { sk.remove_points(&[c]); } - // Indices may have shifted when the corner point was removed. - let fix = |i: usize| if !used && c != 0 && i > c { i - 1 } else { i }; - Ok((fix(ta), fix(tb), pc)) + // Indices shift when the corner point was removed. + let fix = |i: usize| if removed && i > c { i - 1 } else { i }; + Ok((fix(ta), fix(tb), pc, kept.unwrap_or_default())) } fn fillet(s: &mut Session, p: &Value) -> Result { @@ -541,9 +605,10 @@ fn fillet(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "radius must be positive")); } let pname = doc.new_model_param(&r_expr, "mm"); - let (mut arcs, mut cons) = (Vec::new(), Vec::new()); + let (mut arcs, mut cons, mut kept) = (Vec::new(), Vec::new(), Vec::new()); for q in &corners { - let (arc, mut c) = fillet_corner(sk, q, r, cmd)?; + let (arc, mut c, k) = fillet_corner(sk, q, r, cmd)?; + kept.push(k); // One radius for them all: the first carries the dimension, the rest are equal to it. match arcs.first() { None => c.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?), @@ -552,14 +617,27 @@ fn fillet(s: &mut Session, p: &Value) -> Result { cons.extend(c); arcs.push(arc); } - Ok((ids_of(sk, &arcs), cons, pname)) + Ok((ids_of(sk, &arcs), cons, pname, kept_json(kept))) })?; - Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "sketch": info})) + let (vc, moved) = out.3; + Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info})) +} + +/// The virtual corners kept and the constraints moved to them, for a response. +fn kept_json(kept: Vec) -> (Vec, Vec) { + let vcs = kept.iter().filter_map(|k| k.virtual_corner.clone()).collect(); + let mut moved: Vec = Vec::new(); + for id in kept.into_iter().flat_map(|k| k.moved) { + if !moved.contains(&id) { + moved.push(id); + } + } + (vcs, moved) } /// Round one corner (`point` or `a`, `b`) with an arc of radius `r` tangent to both curves: -/// the arc and the tangent constraints. -fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec)> { +/// the arc, the tangent constraints and what became of the corner point. +fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec, CornerKept)> { let (la, lb, c, fa, fb) = match corner(sk, p, cmd) { Ok(x) => x, // Not two lines: a corner with an arc. @@ -578,7 +656,7 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize } let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?; let center = pc + bis * (r / half.sin()); - let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?; + let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?; let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default()); let ccw = (qa - center).cross(qb - center) > 0.0; let arc = if ccw { @@ -587,13 +665,13 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)? }; let cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?]; - Ok((arc, cons)) + Ok((arc, cons, kept)) } /// Fillet a corner where a line meets an arc, or two arcs meet: the arc of radius `r` touching /// both, nearest the corner; both curves are cut back to it. None when the corner is not of /// that kind. -fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result)>> { +fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result, CornerKept)>> { let ends = |c: usize| match sk.curves.get(c).map(|c| &c.kind) { Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => Some((*a, *b)), _ => None, @@ -655,14 +733,15 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result Ok(Some((arc, cons, kept))), + None => { + let arc_id = sk.curves.get(arc).map(|c| c.id.clone()).unwrap_or_default(); + sk.remove_points(&[q]); + let arc = sk.curve_index(&arc_id).unwrap_or(arc); + Ok(Some((arc, cons, CornerKept::default()))) + } } - Ok(Some((arc, cons))) } fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Result<(f64, f64)>) -> Result { @@ -672,11 +751,12 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Res let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y)); let corner_angle = u.cross(v).atan2(u.dot(v)).abs(); let (da, db) = dists(corner_angle)?; - let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?; + let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?; let l = sk.add_line_pts(ta, tb, None)?; - Ok(ids_of(sk, &[l])) + Ok((ids_of(sk, &[l]), kept_json(vec![kept]))) })?; - Ok(json!({"curves": out, "sketch": info})) + let (curves, (vc, moved)) = out; + Ok(json!({"curves": curves, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info})) } fn chamfer_equal(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 05eab29d..ce5fddb1 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -95,6 +95,13 @@ pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool out.push((seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect(), col, c.construction || c.centerline)); } } + // Virtual sharp corners (kept by fillets and chamfers): thin extension lines from the + // curves to the corner, like construction geometry. + for (_, ext) in sk.virtual_corners() { + for (a, b) in ext { + out.push((vec![plane.to_world(a), plane.to_world(b)], colors::SKETCH_CONSTRUCTION, true)); + } + } // 3D curves are in world coordinates already. for w in sk.wires.iter().filter(|w| !w.fit.is_empty() || !sk.view.hide_projected) { // Curves drawn in a 3D sketch look like the sketch's own; included ones are references. diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 35d80e35..8f1be9fd 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -361,6 +361,10 @@ impl ConstraintKind { LinearDiameter { .. } => "LinearDiameterDimension", } } + /// Does the constraint refer to point `p` (by index)? + pub fn refers_to_point(&self, p: usize) -> bool { + self.indices().0.contains(&p) + } fn indices(&self) -> (Vec, Vec) { use ConstraintKind::*; match *self { @@ -892,6 +896,48 @@ impl Sketch { out } + /// Virtual sharp corners (what a fillet or chamfer leaves where dimensions measured to the + /// corner): points used by no curve, held on two or more lines or arcs (their extensions). + /// Each with the extension segments from the corner to the nearest end of those curves, for + /// drawing. + pub fn virtual_corners(&self) -> Vec<(usize, Vec<(Vec2, Vec2)>)> { + let mut out = Vec::new(); + for (i, p) in self.points.iter().enumerate().skip(1) { + if self.point_used_by_curve(i) { + continue; + } + let on: Vec = self + .constraints + .iter() + .filter_map(|c| match c.kind { + ConstraintKind::PointOnCurve { p, c } if p == i => Some(c), + _ => None, + }) + .filter(|c| matches!(self.curves.get(*c).map(|c| &c.kind), Some(CurveKind::Line { .. } | CurveKind::Arc { .. }))) + .collect(); + if on.len() < 2 { + continue; + } + let mut ext = Vec::new(); + for c in on { + let Some((a, b)) = self.curves.get(c).and_then(|c| c.kind.ends()) else { continue }; + let (Some(pa), Some(pb)) = (self.point(a), self.point(b)) else { continue }; + let near = if p.pos.dist(pa) <= p.pos.dist(pb) { pa } else { pb }; + // A line only needs one when the corner is beyond its ends. + let inside = matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })) && { + let d = pb - pa; + let t = (p.pos - pa).dot(d) / d.dot(d).max(1e-300); + (0.0..=1.0).contains(&t) + }; + if !inside && p.pos.dist(near) > 1e-9 { + ext.push((p.pos, near)); + } + } + out.push((i, ext)); + } + out + } + /// Is curve `c` held in place: fixed or projected (linked). pub fn curve_locked(&self, c: usize) -> bool { self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some() || self.freeform_offsets.iter().any(|offset| offset.output == c.id)) diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index dfb11e18..9cd55559 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2112,6 +2112,7 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let Some(ss) = st.sketch(sid) else { return }; let sk = &ss.sketch; let locked = sk.locked_points(); + let virtual_corners: Vec = sk.virtual_corners().into_iter().map(|(i, _)| i).collect(); for (i, p) in sk.points.iter().enumerate() { if !solvecraft_engine::view::sketch_point_visible(sk, i) { continue; @@ -2128,6 +2129,9 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { }; if i == 0 { painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, t.body_edge)); + } else if virtual_corners.contains(&i) { + // A virtual sharp corner: an open square, it is not on any drawn curve. + painter.rect_stroke(Rect::from_center_size(sp, vec2(6.0, 6.0)), 0.0, Stroke::new(1.2, c), egui::StrokeKind::Middle); } else { painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c); }