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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 07:50:35 +00:00
1 parent bbd0a19260
commit f6b42f8750
3 files changed
+214 -22

No files matched your search

+101 -7
View File
@@ -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<Value> {
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<Value> {
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!({
+93
View File
@@ -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));
}
+20 -15
View File
@@ -48,9 +48,9 @@ pub struct ViewportState {
pub build_ms: f64,
/// The right-click menu, open at this screen position.
pub context_menu: Option<Pos2>,
/// 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<Vec2>)>,
}
/// 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;