From d744b760fe5153f0bc082c20b1bd142fc296c2b2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 12:20:44 +0200 Subject: [PATCH] Direct Selection points: each moves in its own spread; Delete deletes them; Transform measures them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `path.moveAnchors` points may carry their own spread-space movement (`[id, subpath, index, dx, dy]`). Dragging and the arrow keys send each selected point's movement converted for its spread, so on a spread turned on screen a point moves the way the pointer or arrow does. Delete and Backspace with points selected run `path.deletePoints`. Properties › Transform for selected points shows the points' bounds; X/Y move them and W/H spread or gather them about the reference point, in one step (`transform_anchors`). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/anchors.rs | 21 ++++++ crates/engine/src/cmd/object.rs | 26 +++++-- crates/tools/src/select.rs | 33 +++++++-- crates/tools/src/tests.rs | 22 +++++- crates/ui-egui/src/panels/mod.rs | 23 +++++- crates/ui-egui/src/panels/properties.rs | 99 ++++++++++++++++++++++++- 6 files changed, 205 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index 472d54494..6adc9b5c6 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -501,6 +501,27 @@ mod tests { } /// Join Path closes one open path, and joins two open paths at their nearest ends. + /// `path.moveAnchors` points may each carry their own movement (Direct Selection sends each + /// point's in its own spread); without one a point takes the shared `dx`, `dy`. + #[test] + fn move_anchors_takes_a_movement_per_point() { + let mut s = crate::Session::new(); + s.execute("file.new", &serde_json::json!({})).unwrap(); + let id = s.execute("frame.create", &serde_json::json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let at = |s: &crate::Session, i: usize| { + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + it.xf * it.path.subpaths[0].anchors[i].p + }; + let (a0, a1) = (at(&s, 0), at(&s, 1)); + s.execute("path.moveAnchors", &serde_json::json!({"points": [[id, 0, 0, 5, 0], [id, 0, 1]], "dx": 0, "dy": 7})).unwrap(); + assert_eq!(at(&s, 0) - a0, designcraft_geom::Vec2::new(5.0, 0.0), "its own"); + assert_eq!(at(&s, 1) - a1, designcraft_geom::Vec2::new(0.0, 7.0), "the shared one"); + for bad in [serde_json::json!([[id, 0, 0, "x", 1]]), serde_json::json!([[id, 0, 0, 5]])] { + assert!(s.execute("path.moveAnchors", &serde_json::json!({"points": bad})).is_err(), "{bad}"); + } + } + #[test] fn join_path_closes_one_path_and_joins_two() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 498dd5674..25ff69eac 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -685,7 +685,7 @@ pub fn specs() -> Vec { "Move Anchors", [], None, - "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index]], dx, dy} (anchors of several paths, as Direct Selection drags them: one step)", + "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index, dx?, dy?]], dx?, dy?} (anchors of several paths, as Direct Selection drags them: one step; a point's own dx, dy win over the shared ones) — spread space", has_doc, path_move_anchors ), @@ -3352,22 +3352,31 @@ fn path_close(s: &mut Session, p: &Value) -> Result { fn path_move_anchors(s: &mut Session, p: &Value) -> Result { // The anchors to move, by path: `points` (several paths) or `id` with `anchors`. - let mut targets: Vec<(ItemId, Vec<(usize, usize)>)> = Vec::new(); + // Each anchor with its own spread-space movement, when the points give one. + let mut targets: Vec<(ItemId, Vec<(usize, usize, Option)>)> = Vec::new(); if let Some(points) = p.get("points").and_then(Value::as_array) { for v in points { let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()); let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else { - return Err(bad("path.moveAnchors", "each point is [id, subpath, index]")); + return Err(bad("path.moveAnchors", "each point is [id, subpath, index] or [id, subpath, index, dx, dy]")); + }; + let own = match (v.get(3), v.get(4)) { + (None, None) => None, + (Some(x), Some(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Vec2::new(x, y)), + _ => return Err(bad("path.moveAnchors", "a point's dx and dy are finite numbers")), + }, + _ => return Err(bad("path.moveAnchors", "a point gives both dx and dy, or neither")), }; match targets.iter_mut().find(|(i, _)| i.0 == id) { - Some((_, which)) => which.push((si, ai)), - None => targets.push((ItemId(id), vec![(si, ai)])), + Some((_, which)) => which.push((si, ai, own)), + None => targets.push((ItemId(id), vec![(si, ai, own)])), } } } else { let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?; let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); - targets.push((id, which)); + targets.push((id, which.into_iter().map(|(s, a)| (s, a, None)).collect())); } let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let handle = str_param(p, "handle").map(str::to_string); @@ -3381,9 +3390,10 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result { let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); // Spread-space delta → inner-space delta (linear part only). let inv = m.inverse(); - let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO); + let inner = |d: Vec2| (inv * Point::new(d.x, d.y)) - (inv * Point::ZERO); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - for (si, ai) in which { + for (si, ai, own) in which { + let dv = inner(own.unwrap_or(d0)); let Some(a) = it.path.anchor_mut(*si, *ai) else { continue }; match handle.as_deref() { Some("in") => { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index cafddc699..3d52913de 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -263,6 +263,18 @@ fn anchor_selection(cx: &ToolContext, points: &[(ItemId, usize, usize)], keep: b json!({"ids": ids, "anchors": anchors}) } +/// `path.moveAnchors` points for `points` moved `d` on the canvas: each with its own movement in +/// its spread (a rotated spread view turns it). +fn moved_points(cx: &ToolContext, points: impl IntoIterator, d: Vec2) -> Vec { + points + .into_iter() + .map(|(i, s, a)| { + let v = cx.doc.find(i).map_or(d, |loc| cx.layout.delta_to_spread(loc.spread, d)); + json!([i.0, s, a, v.x, v.y]) + }) + .collect() +} + /// `d` held to the nearest multiple of 45°, as Shift does. fn constrain_45(d: Vec2) -> Vec2 { if d.hypot() < 1e-9 { @@ -1212,12 +1224,14 @@ impl Tool for SelectionTool { dy += corr.y; self.guides = hit.guides; } - let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy}); + // The command moves in spread space: each point by the drag in its own spread. + let own = at.map_or(Vec2::new(dx, dy), |(spread, _)| cx.layout.delta_to_spread(spread, Vec2::new(dx, dy))); + let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": own.x, "dy": own.y}); if let Some(h) = handle { params["handle"] = json!(h); } else if !others.is_empty() { - let points: Vec = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| json!([i, s, a])).collect(); - params = json!({"points": points, "dx": dx, "dy": dy}); + let points = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| (ItemId(i), s, a)); + params = json!({"points": moved_points(cx, points, Vec2::new(dx, dy))}); } vec![Action::Preview("path.moveAnchors".into(), params)] } @@ -1300,15 +1314,20 @@ impl Tool for SelectionTool { if key == ToolKey::Escape && self.live.take().is_some_and(|id| cx.selection.items == [id]) { return vec![Action::Handled]; } - let points: Vec = cx.selection.anchors.iter().map(|(i, s, a)| json!([i.0, s, a])).collect(); - let direct_points = self.direct && !points.is_empty(); + let direct_points = self.direct && !cx.selection.anchors.is_empty(); let mv = |dx: f64, dy: f64| { - // Direct Selection with anchor points selected: the arrow keys move the points. + // Direct Selection with anchor points selected: the arrow keys move the points, each + // the way the arrow points on screen. if direct_points { - return vec![Action::Exec("path.moveAnchors".into(), json!({"points": points.clone(), "dx": dx, "dy": dy}))]; + let points = moved_points(cx, cx.selection.anchors.iter().copied(), Vec2::new(dx, dy)); + return vec![Action::Exec("path.moveAnchors".into(), json!({"points": points}))]; } vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))] }; + // Selected points: Delete and Backspace delete them, not their paths. + if direct_points && matches!(key, ToolKey::Delete | ToolKey::Backspace) { + return vec![Action::Exec("path.deletePoints".into(), json!({}))]; + } if cx.selection.items.is_empty() { // Selected ruler guides: the arrows nudge them, Delete deletes them. if cx.selection.guides.is_empty() { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 9a60be54c..a6ddb26c2 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1990,14 +1990,18 @@ fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_toge assert_eq!(cmd, "path.moveAnchors"); assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}"); // Shift keeps the move horizontal. - assert_eq!((p["dx"].as_f64(), p["dy"].as_f64()), (Some(36.0), Some(0.0))); + // Each point carries its own movement (in its spread): here all the same. + let moves = |p: &serde_json::Value| -> Vec<(f64, f64)> { + p["points"].as_array().unwrap().iter().map(|v| (v[3].as_f64().unwrap(), v[4].as_f64().unwrap())).collect() + }; + assert!(moves(p).iter().all(|&(x, y)| (x - 36.0).abs() < 1e-9 && y.abs() < 1e-9), "{p}"); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 36.0, at.y)), vec![Action::Commit]); // A Shift-drag that starts on a selected point keeps it: all four move, constrained. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 3.0, at.y + 30.0).with_mods(shift)); let Some(Action::Preview(_, p)) = drag.last() else { panic!("{drag:?}") }; assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}"); - assert!(p["dx"].as_f64().unwrap().abs() < 1e-9 && (p["dy"].as_f64().unwrap() - 30.0).abs() < 1e-9, "vertical: {p}"); + assert!(moves(p).iter().all(|&(x, y)| x.abs() < 1e-9 && (y - 30.0).abs() < 1e-9), "vertical: {p}"); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y + 30.0).with_mods(shift)), vec![Action::Commit]); // Shift+click on a selected point takes it out of the selection, on release; the arrow keys // move the points. @@ -2012,4 +2016,18 @@ fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_toge matches!(keys.as_slice(), [Action::Exec(cmd, p)] if cmd == "path.moveAnchors" && p["points"].as_array().map(Vec::len) == Some(4)), "{keys:?}" ); + // Delete deletes the selected points, not their paths. + assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("path.deletePoints".into(), serde_json::json!({}))]); + // On a spread turned a quarter on screen, → moves the points right on screen: up or down in + // the spread, not across it. + let mut turned = d.clone(); + std::sync::Arc::make_mut(&mut turned.spreads[0]).pages[0].view_rotation = 1; + let lt = CanvasLayout::new(&turned, false); + let cxt = ctx(&turned, &sel, &c, <); + let keys = t.key(&cxt, ToolKey::Right, Mods::default()); + let [Action::Exec(_, p)] = keys.as_slice() else { panic!("{keys:?}") }; + let inc = turned.settings.keyboard_increment; + let want = lt.delta_to_spread(SpreadRef::Doc(0), designcraft_geom::Vec2::new(inc, 0.0)); + assert!(want.x.abs() < 1e-9 && (want.y.abs() - inc).abs() < 1e-9, "a quarter turn: {want:?}"); + assert!(moves(p).iter().all(|&(x, y)| (x - want.x).abs() < 1e-9 && (y - want.y).abs() < 1e-9), "{p}"); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index f2229b604..d1b532ff8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -160,6 +160,22 @@ pub fn guide_field(app: &DesignApp) -> Option { value.is_finite().then_some(GuideField { guide: *g, orientation: guide.orientation, value, origin, unit }) } +/// The anchor points selected with Direct Selection, each with where it is in its spread. +pub fn selected_anchor_points(app: &DesignApp) -> Vec<((designcraft_doc::ItemId, usize, usize), designcraft_geom::Point)> { + let Some(st) = app.session.active() else { return vec![] }; + let d = &st.doc; + st.selection + .anchors + .iter() + .filter_map(|&(id, si, ai)| { + let loc = d.find(id)?; + let it = d.item(id)?; + let a = it.path.subpaths.get(si)?.anchors.get(ai)?; + Some(((id, si, ai), (d.parent_xf(&loc) * it.xf) * a.p)) + }) + .collect() +} + pub fn sel_info(app: &DesignApp) -> Option { let st = app.session.active()?; if st.selection.items.is_empty() { @@ -181,7 +197,12 @@ pub fn sel_info(app: &DesignApp) -> Option { }; b = Some(b.map_or(ib, |r| r.union(ib))); } - let b = b?; + // Points selected with Direct Selection: Transform measures the points, not their paths. + let points = selected_anchor_points(app); + let b = match points.split_first() { + Some(((_, p0), rest)) => rest.iter().fold(Rect::from_points(*p0, *p0), |r, (_, p)| r.union_pt(*p)), + None => b?, + }; let first = d.item(st.selection.items[0])?; let loc = d.find(first.id)?; let o = d.ruler_origin(loc.spread, b.center().x)?; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 4fb578f06..2a1d29f13 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -630,6 +630,72 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } } +/// Properties › Transform for points selected with Direct Selection: X/Y/W/H of the points' +/// bounds (from the reference point); X and Y move the points, W and H spread or gather them +/// about the reference point. No angles: those turn objects. +fn anchor_transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { + let u = units(app); + let rp = app.ui.ref_point; + let b = block(ui, 2.0 * FIELD_H + 4.0); + let r = i.page_rect; + let (fx, fy) = ((rp % 3) as f64 / 2.0, (rp / 3) as f64 / 2.0); + let (x, y) = (r.x0 + r.width() * fx, r.y0 + r.height() * fy); + if let Some(p) = ref_point_proxy(ui, sub(b, 5.0, 10.0, 26.0, 26.0), rp) { + app.ui.ref_point = p; + } + let mut set = serde_json::Map::new(); + for (row, (lx, vx, kx, lw, vw, kw)) in + [("X:", x, "x", "W:", r.width(), "width"), ("Y:", y, "y", "H:", r.height(), "height")].into_iter().enumerate() + { + let yy = row as f32 * 25.0; + place(ui, sub(b, 40.0, yy, 18.0, FIELD_H), |ui| widgets::caption_w(ui, lx, 18.0)); + if let Some(v) = place(ui, sub(b, 59.0, yy, 54.0, FIELD_H), |ui| measure(ui, &format!("pt_{kx}"), Some(vx), u, 54.0)) { + set.insert(kx.into(), json!(v)); + } + place(ui, sub(b, 120.0, yy, 19.0, FIELD_H), |ui| widgets::caption_w(ui, lw, 19.0)); + if let Some(v) = place(ui, sub(b, 140.0, yy, 54.0, FIELD_H), |ui| measure(ui, &format!("pt_{kw}"), Some(vw), u, 54.0)) { + set.insert(kw.into(), json!(v)); + } + } + if !set.is_empty() { + let _ = transform_anchors(app, &Value::Object(set)); + } + if more_options(ui, &app.ui.language).clicked() { + show_panel(app, "info"); + } +} + +/// Move the selected points as Properties › Transform's `set` asks ({x?, y?, width?, height?}, +/// in the page coordinates the fields show, from the reference point): one `path.moveAnchors` +/// step, each point by its own movement. A width or height of a single point (0) can't scale. +pub(crate) fn transform_anchors(app: &mut DesignApp, set: &Value) -> Result { + let points = super::selected_anchor_points(app); + let Some(i) = super::sel_info(app) else { return Ok(Value::Null) }; + if points.is_empty() { + return Ok(Value::Null); + } + let rp = app.ui.ref_point; + let r = i.page_rect; + let (fx, fy) = ((rp % 3) as f64 / 2.0, (rp / 3) as f64 / 2.0); + // Page coordinates and spread coordinates differ by the ruler origin only. + let b = points.iter().skip(1).fold(designcraft_geom::Rect::from_points(points[0].1, points[0].1), |b, (_, p)| b.union_pt(*p)); + let origin = (b.x0 - r.x0, b.y0 - r.y0); + let reference = (r.x0 + r.width() * fx + origin.0, r.y0 + r.height() * fy + origin.1); + let num = |k: &str| set.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + let scale = |new: Option, old: f64| new.filter(|_| old > 1e-9).map_or(1.0, |v| v.max(0.0) / old); + let (kx, ky) = (scale(num("width"), r.width()), scale(num("height"), r.height())); + let shift = (num("x").map_or(0.0, |v| v - (r.x0 + r.width() * fx)), num("y").map_or(0.0, |v| v - (r.y0 + r.height() * fy))); + let moved: Vec = points + .iter() + .map(|&((id, s, a), p)| { + let nx = reference.0 + (p.x - reference.0) * kx + shift.0; + let ny = reference.1 + (p.y - reference.1) * ky + shift.1; + json!([id.0, s, a, nx - p.x, ny - p.y]) + }) + .collect(); + app.run("path.moveAnchors", json!({"points": moved})) +} + /// Properties › Transform for selected ruler guides: X for one vertical guide, Y for one /// horizontal guide; with several guides both fields are empty. fn guide_section(app: &mut DesignApp, ui: &mut Ui) { @@ -2256,7 +2322,7 @@ const CONVERT_SHAPE: [(&str, &str, &str); 9] = [ /// Appearance, Convert Points (the point type of the selected points) and Delete Point(s). fn anchor_point_sections(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { section(ui, crate::i18n::tr(&app.ui.language, "Transform"), true); - transform_section(app, ui, i); + anchor_transform_section(app, ui, i); divider(ui); section(ui, crate::i18n::tr(&app.ui.language, "Appearance"), true); appearance_section(app, ui, i); @@ -3512,6 +3578,37 @@ mod tests { } } + /// With points selected, Transform measures and moves the points: one corner of a frame shows + /// that corner, not the frame, and X moves the corner alone; W spreads two points apart. + #[test] + fn transform_measures_and_moves_the_selected_points() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let corner = |app: &DesignApp, i: usize| { + let d = &app.session.active().unwrap().doc; + let it = d.item(designcraft_doc::ids::ItemId(a)).unwrap(); + it.xf * it.path.subpaths[0].anchors[i].p + }; + let first = corner(&app, 0); + app.run("selection.set", json!({"anchors": [[a, 0, 0]]})).unwrap(); + let i = super::super::sel_info(&app).unwrap(); + assert_eq!((i.page_rect.width(), i.page_rect.height()), (0.0, 0.0), "a point, not the frame"); + app.ui.ref_point = 0; + transform_anchors(&mut app, &json!({"x": i.page_rect.x0 + 10.0})).unwrap(); + assert_eq!(corner(&app, 0), first + designcraft_geom::Vec2::new(10.0, 0.0), "the corner moved"); + assert_eq!(corner(&app, 2), designcraft_geom::Point::new(172.0, 172.0), "the opposite corner stayed"); + // Two points 100 pt apart, W 200 from the left: the right one goes 100 further. + app.run("selection.set", json!({"anchors": [[a, 0, 2], [a, 0, 3]]})).unwrap(); + let (p2, p3) = (corner(&app, 2), corner(&app, 3)); + let w = super::super::sel_info(&app).unwrap().page_rect.width(); + transform_anchors(&mut app, &json!({"width": w * 2.0})).unwrap(); + let (left, right) = if p2.x < p3.x { (2, 3) } else { (3, 2) }; + let (pl, pr) = if left == 2 { (p2, p3) } else { (p3, p2) }; + assert_eq!(corner(&app, left), pl, "the reference side stays"); + assert!((corner(&app, right).x - (pr.x + w)).abs() < 1e-9, "spread to twice the width"); + } + #[test] fn transform_flips_and_rotates_from_properties() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());