Fix #1408: the Transform tools and commands act on the anchors selected with Direct Selection

This commit is contained in:
bflatastic committed 2026-10-12 21:01:15 +07:00
1 parent 003234a056
commit a41d59604b
5 files changed
+135 -37

No files matched your search

+20 -1
View File
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
use serde::{Deserialize, Serialize};
use crate::{Document, NodeId};
use crate::{Document, NodeId, NodeKind};
/// (subpath index, anchor index) inside a path.
pub type AnchorRef = (usize, usize);
@@ -125,6 +125,25 @@ impl Selection {
pub fn partial(&self, id: NodeId) -> Option<&BTreeSet<AnchorRef>> {
self.anchors.get(&id)
}
/// The objects selected whole: those with no direct-selected anchors.
pub fn whole(&self) -> Vec<NodeId> {
self.objects.iter().copied().filter(|id| !self.anchors.contains_key(id)).collect()
}
/// With direct-selected anchors, what a transform of them measures from: the box around those
/// anchors and the objects selected whole (`visual`: with their strokes). `None` without any.
pub fn anchor_bounds(&self, doc: &Document, visual: bool) -> Option<vectorcraft_geom::Rect> {
if self.anchors.is_empty() {
return None;
}
let mut acc = doc.bounds_of(&self.whole(), visual);
for (id, set) in &self.anchors {
let Some(NodeKind::Path { path, .. }) = doc.node(*id).map(|n| &n.kind) else { continue };
for a in set.iter().filter_map(|&(si, ai)| path.subpaths.get(si)?.anchors.get(ai)) {
acc = vectorcraft_geom::union_opt(acc, Some(vectorcraft_geom::Rect::from_points(a.p, a.p)));
}
}
acc
}
/// Which direction handles belong to selected segments; both for an anchor selection.
pub fn handle_sides(&self, id: NodeId, si: usize, ai: usize, sp: &vectorcraft_geom::SubPath) -> (bool, bool) {
let Some(segments) = self.segments.get(&id) else { return (true, true) };
+36 -21
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transform",
[],
None,
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, anchors?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); anchors (without ids or copy): paths with anchors selected with Direct Selection have only those anchors and their handles transformed, as the Transform tools and object.move/rotate/scale/reflect/shear do (default: the whole objects); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
has_doc,
transform
),
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move…",
["Object", "Transform"],
Some("Cmd+Shift+M"),
"{dx, dy, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
"{dx, dy, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
has_selection,
move_cmd
),
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rotate…",
["Object", "Transform"],
None,
"{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} → {ids}",
"{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} → {ids}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
has_selection,
rotate
),
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Scale…",
["Object", "Transform"],
None,
"{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} (strokes/corners default to the preferences)",
"{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
has_selection,
scale
),
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reflect…",
["Object", "Transform"],
None,
"{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
"{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
has_selection,
reflect
),
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shear…",
["Object", "Transform"],
None,
"{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
"{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
has_selection,
shear
),
@@ -223,6 +223,13 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
if let Some(o) = point_param(p, "origin") {
return Ok(o);
}
// Direct-selected anchors transform alone (see [`apply_transform`]), about their own centre.
let st = s.doc()?;
if !bool_or(p, "copy", false)
&& let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds)
{
return Ok(b.center());
}
s.transform_box(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into()))
}
@@ -263,7 +270,10 @@ pub(crate) fn transform_patterns(s: &mut Session, p: &Value, ids: &[NodeId]) ->
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`];
/// `patterns`: see [`transform_patterns`]; `typeAreas`: see [`resize_type_area`]). Outside an
/// interaction, records `xf` and the options it used for Transform Again.
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, p: &Value) -> Result<Value> {
///
/// `anchors` (`ids` being the selection): with anchors selected with Direct Selection and no
/// copy, only those anchors transform, their handles with them, and the objects selected whole.
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, p: &Value, anchors: bool) -> Result<Value> {
let copy = bool_or(p, "copy", false);
let areas = bool_or(p, "typeAreas", false);
let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
@@ -273,7 +283,12 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
// With a copy, the reply lists the copies, and there are none.
let d = &s.doc()?.doc;
let none = d.index_paths(ids.iter().copied()).is_empty();
let ids = if none || (!copy && xf == Affine::IDENTITY) {
let ids = if anchors && !copy && !s.doc()?.selection.anchors.is_empty() {
if xf != Affine::IDENTITY {
super::path::transform_anchors(s, label, xf, sc)?;
}
ids
} else if none || (!copy && xf == Affine::IDENTITY) {
if copy { vec![] } else { ids }
} else {
s.edit(label, |d, sel| {
@@ -316,16 +331,16 @@ fn resize_type_area(n: &mut Node, xf: Affine) -> bool {
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
let (ids, anchors) = match ids_param(p, "ids") {
Some(v) => (v, false),
None => (selected_roots(s)?, bool_or(p, "anchors", false)),
};
apply_transform(s, "Transform", ids, m, p)
apply_transform(s, "Transform", ids, m, p, anchors)
}
fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p)
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true)
}
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
@@ -348,7 +363,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy }));
}
let ids = selected_roots(s)?;
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p)
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p, true)
}
fn about(o: Point, a: Affine) -> Affine {
@@ -364,7 +379,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
}
// Angles are counter-clockwise; y is down, so negate.
let a = about(o, Affine::rotate(-angle.to_radians()));
apply_transform(s, "Rotate", ids, a, p)
apply_transform(s, "Rotate", ids, a, p, true)
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
@@ -375,7 +390,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
if sx == 0.0 || sy == 0.0 {
return Err(bad("object.scale", "scale must be non-zero"));
}
apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p)
apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p, true)
}
fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
@@ -394,7 +409,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`")));
}
};
apply_transform(s, "Reflect", ids, about(o, m), p)
apply_transform(s, "Reflect", ids, about(o, m), p, true)
}
fn shear(s: &mut Session, p: &Value) -> Result<Value> {
@@ -403,7 +418,7 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical");
let m = if vertical { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
apply_transform(s, "Shear", ids, about(o, m), p)
apply_transform(s, "Shear", ids, about(o, m), p, true)
}
/// The last transform's matrix on the selection, with the options `last_transform` keeps for it; an
@@ -417,7 +432,7 @@ fn transform_again(s: &mut Session, p: &Value) -> Result<Value> {
o.extend(given.iter().filter(|(_, v)| v.is_boolean()).map(|(k, v)| (k.clone(), v.clone())));
}
let ids = selected_roots(s)?;
apply_transform(s, "Transform Again", ids, m, &options)
apply_transform(s, "Transform Again", ids, m, &options, true)
}
enum Arrange {
@@ -1311,10 +1326,10 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
// leave a rounding error in it on a rotated object or with Use Preview Bounds.
let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now);
if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) {
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p);
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p, false);
}
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p)
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false)
}
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
+15 -12
View File
@@ -168,35 +168,38 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let d = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let st = s.doc()?;
let sel = st.selection.clone();
let scale_strokes = s.prefs.scale_strokes;
let _ = scale_strokes;
// Objects selected whole (with no entry in `sel.anchors`) move as objects, and their pattern
// tiles move with them when `patterns` (default: General › Transform Pattern Tiles) is on.
let whole: Vec<NodeId> = sel.objects.iter().copied().filter(|id| !sel.anchors.contains_key(id)).collect();
// Objects selected whole move as objects, and their pattern tiles move with them when
// `patterns` (default: General › Transform Pattern Tiles) is on.
let whole = s.doc()?.selection.whole();
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &whole)?, ..Scaling::default() };
s.edit("Move", |doc, _| {
transform_anchors(s, "Move", vectorcraft_geom::Affine::translate(d), sc)?;
ok()
}
/// Apply `xf` to the direct-selected anchors, their handles with them, and (with `sc`) to the
/// objects selected whole (those with no entry in the selection's `anchors`).
pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_geom::Affine, sc: Scaling) -> Result<()> {
let sel = s.doc()?.selection.clone();
s.edit(label, |doc, _| {
for id in &sel.objects {
match sel.anchors.get(id) {
Some(set) => {
let path = path_mut(doc, *id)?;
for &(si, ai) in set {
if let Some(a) = path.anchor_mut(si, ai) {
a.translate(d);
*a = a.transform(xf);
}
}
}
None => {
if let Some(n) = doc.node_mut(*id) {
n.transform(vectorcraft_geom::Affine::translate(d), sc);
n.transform(xf, sc);
}
}
}
}
Ok(())
})?;
ok()
})
}
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
+54
View File
@@ -764,6 +764,60 @@ fn a_transform_that_moves_nothing_records_no_undo_step() {
assert_eq!(count(&s), n + 1);
}
/// #1408: with anchors selected with Direct Selection, the Transform commands and tools transform
/// those anchors alone, about the centre of their own box; a copy still copies the whole object.
#[test]
fn transforms_act_on_direct_selected_anchors() {
let mut s = session();
let square = json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}], "closed": true});
let id = NodeId(s.execute("path.create", &square).unwrap()["id"].as_u64().unwrap());
let points = |s: &Session| -> Vec<(f64, f64)> {
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a path") };
path.subpaths[0].anchors.iter().map(|a| (a.p.x.round(), a.p.y.round())).collect()
};
let top = json!({"id": id.0, "anchors": [[0, 0], [0, 1]]});
s.execute("select.anchors", &top).unwrap();
let cases = [
("object.scale", json!({"sx": 50}), [(25.0, 0.0), (75.0, 0.0)]),
("object.scale", json!({"sx": 50, "origin": [0, 100]}), [(0.0, 50.0), (50.0, 50.0)]),
("object.rotate", json!({"angle": 90}), [(50.0, 50.0), (50.0, -50.0)]),
("object.reflect", json!({"axis": "vertical"}), [(100.0, 0.0), (0.0, 0.0)]),
("object.shear", json!({"angle": 45, "axis": "vertical", "origin": [0, 0]}), [(0.0, 0.0), (100.0, 100.0)]),
("object.move", json!({"dx": 10, "dy": -10}), [(10.0, -10.0), (110.0, -10.0)]),
("object.transform", json!({"matrix": [1, 0, 0, 1, 5, 5], "anchors": true}), [(5.0, 5.0), (105.0, 5.0)]),
];
for (cmd, p, [a, b]) in cases {
s.execute(cmd, &p).unwrap();
assert_eq!(points(&s), [a, b, (100.0, 100.0), (0.0, 100.0)], "{cmd} {p}");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.anchors", &top).unwrap();
}
// Transform Again repeats it on the anchors.
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
s.execute("object.transformAgain", &json!({})).unwrap();
assert_eq!(points(&s), [(20.0, 0.0), (120.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.anchors", &top).unwrap();
// Without `anchors`, object.transform (the Selection tool's) takes the whole path, and so does a copy.
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 5]})).unwrap();
assert_eq!(points(&s), [(5.0, 5.0), (105.0, 5.0), (105.0, 105.0), (5.0, 105.0)]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.anchors", &top).unwrap();
let copies = s.execute("object.scale", &json!({"sx": 50, "copy": true})).unwrap();
assert_eq!(copies["ids"].as_array().unwrap().len(), 1);
assert_eq!(points(&s), [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], "the original stays");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.anchors", &top).unwrap();
// The Scale tool: a drag from (100, 50) to (75, 50) halves the top edge about its centre (50, 0).
let v = ViewInfo::default();
s.select_tool("scale", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 50.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 75.0, 50.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 75.0, 50.0), v).unwrap();
assert_eq!(points(&s), [(25.0, 0.0), (75.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
}
/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and
/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to
/// Current Layer on objects already on top of the current layer.
+10 -3
View File
@@ -87,6 +87,12 @@ fn about(o: Point, a: Affine) -> Affine {
Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2())
}
/// The transform `m` of the selection (of a copy with `copy`): of the anchors selected with Direct
/// Selection alone, when there are some.
fn preview(m: Affine, copy: bool) -> Action {
Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(m), "copy": copy, "anchors": true }))
}
/// Reflection across the line through the origin at angle `theta` (radians, screen space).
pub fn reflect_matrix(o: Point, theta: f64) -> Affine {
about(o, Affine::rotate(theta) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-theta))
@@ -106,7 +112,8 @@ impl TransformTool {
{
return Some(*p);
}
selection_box(cx).map(|b| b.center())
// Anchors selected with Direct Selection transform alone, about their own centre.
cx.selection.anchor_bounds(cx.doc, cx.preview_bounds).map(|b| b.center()).or_else(|| selection_box(cx).map(|b| b.center()))
}
/// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout.
@@ -209,14 +216,14 @@ impl Tool for TransformTool {
d.last = m;
self.drag = Some(d);
self.measure = Some((p, text));
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(m), "copy": ev.mods.alt })));
out.push(preview(m, ev.mods.alt));
out
}
PointerKind::Up => {
let Some(d) = self.end_drag() else { return vec![] };
if d.began {
// The Alt state at release decides whether the result is a copy.
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(d.last), "copy": ev.mods.alt })), Action::Commit]
vec![preview(d.last, ev.mods.alt), Action::Commit]
} else {
// A click sets the reference point; with Alt, it opens the dialog too.
self.origin = Some((d.pick, cx.selection.objects.clone()));