From f6b42f87506d8bbab3181775ff23551e87625cef Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 07:37:38 +0000 Subject: [PATCH] Sketch drag feel like Fusion (sketch.drag): the grabbed point goes exactly to the cursor, a line (spline, conic) slides with it, a circle or arc grabbed on its edge changes radius about its centre (an ellipse its minor radius), the centre moves the circle; the grabbed geometry is held while the rest moves as little as it can; fixed and fully constrained geometry stays. Dragging a curve of the sketch being edited now drags it (it used to start a box selection). Tests for per-entity fully-constrained feedback Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/sketch.rs | 108 +++++++++++++++++++++-- crates/engine/src/cmd/sketch_ux_tests.rs | 93 +++++++++++++++++++ crates/ui-egui/src/viewport.rs | 35 ++++---- 3 files changed, 214 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 424e51f6..1d68ade2 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -154,6 +154,9 @@ pub static COMMANDS: &[CommandSpec] = &[ .enabled(in_sketch) .params("entities: [curve, point or constraint ids]"), CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"), + CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).params( + "entity: point or curve, from: [x,y] where it was grabbed, to: [x,y]: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can", + ), CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text) .enabled(in_sketch) .params("dimension: constraint id or parameter name; at: [x,y] text centre (sketch coordinates) | reset: true (default place)"), @@ -1505,18 +1508,109 @@ fn move_point(s: &mut Session, p: &Value) -> Result { let to = req_vec2(cmd, p, "to")?; let (_, info) = edit(s, p, cmd, false, |sk, _| { let q = point_ref(sk, p.get("point"), cmd, "point")?; - let locked = sk.point_locked(q); - if let Some(pt) = sk.points.get_mut(q) - && !locked - { - pt.pos = to; - } - // Move whole circles when their centre moves (keeps the radius). + drag_entity(sk, Ent::Point(q), to, to); Ok(()) })?; Ok(json!({"sketch": info})) } +fn drag(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.drag"; + let to = req_vec2(cmd, p, "to")?; + let from = p.get("from").and_then(vec2).unwrap_or(to); + let (_, info) = edit(s, p, cmd, false, |sk, _| { + let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?; + let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?; + drag_entity(sk, e, from, to); + Ok(()) + })?; + Ok(json!({"sketch": info})) +} + +/// Drag like Fusion: the grabbed entity follows the cursor exactly (a point goes to it; a line, +/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes +/// radius about its centre) and the rest of the sketch moves as little as it can. Fixed and +/// fully constrained geometry stays (the solve that follows pulls it back). +fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) { + let d = to - from; + let mut hold = Hold::default(); + match e { + Ent::Point(q) => { + if sk.point_locked(q) { + return; + } + if let Some(pt) = sk.points.get_mut(q) { + pt.pos = to; + } + hold.points.push(q); + } + Ent::Curve(c) => { + if sk.curve_locked(c) { + return; + } + let Some(kind) = sk.curves.get(c).map(|c| c.kind.clone()) else { return }; + let locked = sk.locked_points(); + let set = |sk: &mut Sketch, q: usize, at: Vec2| { + if !locked.get(q).copied().unwrap_or(true) + && let Some(pt) = sk.points.get_mut(q) + { + pt.pos = at; + } + }; + match kind { + CurveKind::Circle { c: cc, .. } => { + let r = sk.point(cc).map(|o| o.dist(to)).unwrap_or(0.0); + if let Some(CurveKind::Circle { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind) + && r > 1e-9 + { + *slot = r; + } + hold.points.push(cc); + hold.radii.push(c); + } + CurveKind::Arc { c: cc, a, b } => { + let Some(o) = sk.point(cc) else { return }; + let r = o.dist(to); + if r > 1e-9 { + for q in [a, b] { + if let Some(u) = sk.point(q).and_then(|pq| (pq - o).normalized()) { + set(sk, q, o + u * r); + } + } + } + hold.points.extend([cc, a, b]); + } + CurveKind::Ellipse { c: cc, m, .. } => { + // Grabbed on the outline: the minor radius follows (the major stays). + let (Some(o), Some(pm)) = (sk.point(cc), sk.point(m)) else { return }; + let u = (pm - o).normalized().unwrap_or(Vec2::X); + let minor = u.cross(to - o).abs(); + if minor > 1e-9 + && let Some(CurveKind::Ellipse { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind) + { + *slot = minor; + } + hold.points.extend([cc, m]); + hold.radii.push(c); + } + k => { + // Lines, splines and conics slide with the cursor. + for q in k.point_ids() { + if let Some(at) = sk.point(q) { + set(sk, q, at + d); + } + } + hold.points.extend(k.point_ids()); + } + } + } + } + let mut trial = sk.clone(); + if solve_holding(&mut trial, &hold).ok() { + *sk = trial; + } +} + /// The Sketch Palette options of a sketch, by their palette names (true = shown / on). fn options_json(v: &solvecraft_sketch::SketchView) -> Value { json!({ diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index bbf5e886..b354d4bf 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -433,3 +433,96 @@ fn palette_options_are_kept_with_each_sketch() { run(&mut s, "UndoCommand", json!({})); assert!(!sketch(&s).view.hide_points); } + +// --------------------------------------------------------------------------------------------- +// Dragging + +fn drag(s: &mut Session, e: &str, from: [f64; 2], to: [f64; 2]) { + run(s, "sketch.drag", json!({"entity": e, "from": from, "to": to})); +} + +fn v(x: f64, y: f64) -> solvecraft_geom::Vec2 { + solvecraft_geom::Vec2::new(x, y) +} + +#[test] +fn dragging_a_line_slides_it_and_an_end_keeps_the_other_end() { + let mut s = new_sketch(); + run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 20]]})); + drag(&mut s, "l1", [25.0, 15.0], [30.0, 25.0]); + assert!(pt(&s, "l1.start").dist(v(15.0, 20.0)) < 1e-9 && pt(&s, "l1.end").dist(v(45.0, 30.0)) < 1e-9); + drag(&mut s, "l1.end", [45.0, 30.0], [50.0, 0.0]); + assert!(pt(&s, "l1.end").dist(v(50.0, 0.0)) < 1e-9); + assert!(pt(&s, "l1.start").dist(v(15.0, 20.0)) < 1e-9); +} + +#[test] +fn dragging_a_rectangle_side_moves_that_side_only() { + let mut s = new_sketch(); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [10, 10], "p1": [40, 30]})); + // l2 is the right side (x = 40): dragged right, it stays vertical, the left side stays. + let left = snap(&s, "l4"); + drag(&mut s, "l2", [40.0, 20.0], [50.0, 20.0]); + assert!(same(&snap(&s, "l4"), &left), "left side moved"); + let (a, b) = (pt(&s, "l2.start"), pt(&s, "l2.end")); + assert!((a.x - 50.0).abs() < 1e-6 && (b.x - 50.0).abs() < 1e-6, "{a:?} {b:?}"); +} + +#[test] +fn dragging_circles_and_arcs() { + let mut s = new_sketch(); + run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5})); + // The edge changes the radius about a fixed centre; the centre moves the circle. + drag(&mut s, "c1", [5.0, 0.0], [0.0, 8.0]); + assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9); + assert!(pt(&s, "c1.center").dist(v(0.0, 0.0)) < 1e-9); + drag(&mut s, "c1.center", [0.0, 0.0], [20.0, 5.0]); + assert!(pt(&s, "c1.center").dist(v(20.0, 5.0)) < 1e-9); + assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9); + run(&mut s, "ArcCenterTwoPoint", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]})); + drag(&mut s, "a1", [57.07, 7.07], [50.0 + 200f64.sqrt(), 200f64.sqrt()]); + assert!(pt(&s, "a1.center").dist(v(50.0, 0.0)) < 1e-9); + assert!((snap(&s, "a1").1.unwrap_or(0.0) - 20.0).abs() < 1e-9, "{:?}", snap(&s, "a1")); + assert!(pt(&s, "a1.start").dist(v(70.0, 0.0)) < 1e-9, "{:?}", pt(&s, "a1.start")); +} + +#[test] +fn constrained_geometry_resists_drags() { + let mut s = new_sketch(); + run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 10]]})); + run(&mut s, "ConstraintFix", json!({"entities": ["l1.start", "l1.end"]})); + let l0 = snap(&s, "l1"); + drag(&mut s, "l1", [25.0, 10.0], [25.0, 30.0]); + assert!(same(&snap(&s, "l1"), &l0)); + // A horizontal line dragged at its end stays horizontal; its length dimension holds. + let mut s = new_sketch(); + run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 10]]})); + run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l1"})); + run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 30})); + drag(&mut s, "l1.end", [40.0, 10.0], [45.0, 20.0]); + let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end")); + assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}"); +} + +#[test] +fn fully_constrained_feedback_is_per_entity() { + let mut s = new_sketch(); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [10, 10], "p1": [50, 35]})); + run(&mut s, "CircleCenterRadius", json!({"center": [80, 20], "radius": 8})); + run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 40})); + run(&mut s, "SketchDimension", json!({"entities": ["l2"], "value": 25})); + run(&mut s, "SketchDimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 10})); + run(&mut s, "SketchDimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 10})); + let si = run(&mut s, "sketch.inspect", json!({})); + assert_eq!(si["dof"], 3); + for c in si["curves"].as_array().expect("curves") { + assert_eq!(c["fully_constrained"], c["type"] == "line", "{c}"); + } + // Colours follow: fully constrained lines in the fixed colour, the free circle in blue. + let sk = sketch(&s); + let st = s.model.state(); + let ss = st.sketch(s.active_sketch.expect("sketch")).expect("solved"); + let lines = crate::view::sketch_lines(&sk, &ss.plane, true, &ss.report.curve_determined); + assert!(lines.iter().filter(|l| l.1 == crate::view::colors::SKETCH_FIXED).count() >= 4); + assert!(lines.iter().any(|l| l.1 == crate::view::colors::SKETCH)); +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 067d51b5..c22cdeaa 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -48,9 +48,9 @@ pub struct ViewportState { pub build_ms: f64, /// The right-click menu, open at this screen position. pub context_menu: Option, - /// A sketch point being dragged (its id, and the undo depth when the drag began: the whole - /// drag is one undo step). - pub point_drag: Option<(String, usize)>, + /// A sketch point or curve being dragged (its id, the undo depth when the drag began: the + /// whole drag is one undo step, and for a curve where it was last under the cursor). + pub point_drag: Option<(String, usize, Option)>, } /// Something under the cursor. @@ -1176,26 +1176,31 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { { crate::dim_view::drag_to(app, q); } + // A point or a curve of the sketch being edited follows a drag that starts on it. if resp.drag_started_by(egui::PointerButton::Primary) && !crate::dim_view::dragging() && let Some(a) = ui.input(|i| i.pointer.press_origin()) - && let Some(Hit::SketchPoint { id, .. }) = pick(app, &proj, a).into_iter().next() { - app.viewport.point_drag = Some((id, app.session.undo.len())); - } - if let Some((id, depth)) = app.viewport.point_drag.clone() - && resp.dragged_by(egui::PointerButton::Primary) - && let Some(p) = hover - && let Some(ss) = app.session.active_sketch.and_then(|sid| app.session.model.state().sketch(sid).cloned()) - && let Some(w) = { - let (o, d) = proj.ray(p); - ss.plane.intersect_ray(o, d) + let active = app.session.active_sketch; + match pick(app, &proj, a).into_iter().next() { + Some(Hit::SketchPoint { id, .. }) => app.viewport.point_drag = Some((id, app.session.undo.len(), None)), + Some(Hit::SketchCurve { sketch, id, .. }) if Some(sketch) == active => { + app.viewport.point_drag = on_plane(app, a).map(|q| (id, app.session.undo.len(), Some(q))); + } + _ => {} } + } + if let Some((id, depth, grab)) = app.viewport.point_drag.clone() + && resp.dragged_by(egui::PointerButton::Primary) + && let Some(q) = hover.and_then(|p| on_plane(app, p)) { - let q = ss.plane.to_local(w); - if app.session.execute("sketch.move_point", &json!({"point": id, "to": [q.x, q.y]})).is_ok() { + let from = grab.unwrap_or(q); + if app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y]})).is_ok() { app.session.undo.truncate(depth + 1); } + if grab.is_some() { + app.viewport.point_drag = Some((id, depth, Some(q))); + } } if resp.drag_stopped() { app.viewport.point_drag = None;