Direct Selection points: each moves in its own spread; Delete deletes them; Transform measures them

`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 <noreply@anthropic.com>
This commit is contained in:
moppeeerandClaude Opus 5.5 committed 2026-10-22 12:20:44 +02:00
1 parent 66b51ee15f
commit d744b760fe
6 files changed
+205 -19

No files matched your search

+21
View File
@@ -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();
+18 -8
View File
@@ -685,7 +685,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
// 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<Vec2>)>)> = 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<Value> {
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") => {
+26 -7
View File
@@ -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<Item = (ItemId, usize, usize)>, d: Vec2) -> Vec<Value> {
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<Value> = 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<Value> = 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() {
+20 -2
View File
@@ -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, &lt);
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}");
}
+22 -1
View File
@@ -160,6 +160,22 @@ pub fn guide_field(app: &DesignApp) -> Option<GuideField> {
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<SelInfo> {
let st = app.session.active()?;
if st.selection.items.is_empty() {
@@ -181,7 +197,12 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> {
};
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)?;
+98 -1
View File
@@ -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<Value, String> {
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<f64>, 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<Value> = 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());