Part of #394: art moved whole with direct-selected anchors follows Transform Pattern Tiles

With anchors of one path direct-selected and another object selected
whole, the arrow keys (object.nudge), path.moveAnchors and a Direct
Selection drag of the selected anchors moved that object without its
pattern tiles, whatever General › Transform Pattern Tiles was set to.
When the same object was selected on its own, the arrow keys and a
Direct Selection drag followed the preference. path.moveAnchors left
the tiles in place in that case too.

path.moveAnchors now follows the preference for the objects it moves
whole, as the transforms, Align and Distribute do. path.moveAnchors
and object.nudge also take a `patterns` param for one call. When those
objects use a pattern, the journal records the choice, and a recorded
action plays back with it. Partly selected paths still move only their
selected anchors.

Docs: docs/mcp.md (transformPatternTiles), docs/target-app-parity.md.
This commit is contained in:
dotCooCoo 2026-10-11 22:20:59 -07:00 committed by bflatastic
parent 497172bc97
commit ae061eacfb
5 changed files with 65 additions and 7 deletions

View File

@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Nudge",
[],
None,
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool} arrow-key nudge by the keyboard increment (the selected anchors, else objects, else ruler guides)",
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} arrow-key nudge by the keyboard increment (the selected anchors, else objects, else ruler guides)",
has_selection_or_guides,
nudge
),
@ -337,7 +337,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
if dx == 0.0 && dy == 0.0 {
return ok();
}
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy }));
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy, "patterns": p.get("patterns") }));
}
if s.doc()?.selection.is_empty() {
let copy = bool_or(p, "copy", false);

View File

@ -3,7 +3,7 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_doc::{NodeId, NodeKind, Scaling};
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2};
use super::create::anchor_from_json;
@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Anchors",
[],
None,
"{dx, dy} move direct-selected anchors (whole paths if fully selected)",
"{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected)",
has_selection,
move_anchors
),
@ -172,6 +172,10 @@ pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
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();
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &whole)?, ..Scaling::default() };
s.edit("Move", |doc, _| {
for id in &sel.objects {
match sel.anchors.get(id) {
@ -185,7 +189,7 @@ pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
}
None => {
if let Some(n) = doc.node_mut(*id) {
n.transform(vectorcraft_geom::Affine::translate(d), false);
n.transform(vectorcraft_geom::Affine::translate(d), sc);
}
}
}

View File

@ -699,6 +699,59 @@ fn art_moved_with_its_artboard_follows_transform_pattern_tiles() {
}
}
/// General › Transform Pattern Tiles (#394) applies to the objects selected whole that move with
/// direct-selected anchors (an arrow key, `path.moveAnchors` or a Direct Selection drag of an
/// anchor): off, the tiles of a pattern-filled square stay in place; on, they move with it, and
/// `patterns: false` keeps them in place for one call.
#[test]
fn whole_objects_moved_with_selected_anchors_follow_transform_pattern_tiles() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
let filled = patterned_square(&mut s);
let other = square(&mut s, 300.0, 100.0);
let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() };
s.select_tool("directSelection", v).unwrap();
// The square is selected whole with the top-left anchor of `other`, as a click on the square's
// fill and a Shift-click on that anchor select them. Each move takes both 10 pt right: Shift
// with an arrow key, `path.moveAnchors`, and a drag of the anchor (no params).
for (cmd, p) in [
("object.nudge", Some(json!({"dx": 1, "dy": 0, "big": true}))),
("path.moveAnchors", Some(json!({"dx": 10, "dy": 0}))),
("path.moveAnchors", None),
] {
let run = |s: &mut Session, p: Option<&Value>| {
s.execute("select.set", &json!({"ids": [filled.0]})).unwrap();
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
match p {
Some(p) => {
s.execute(cmd, p).unwrap();
}
None => gesture(s, v, &[(Down, 300.0, 100.0), (Drag, 305.0, 100.0), (Drag, 310.0, 100.0), (Up, 310.0, 100.0)]),
}
assert_eq!(top_left(s, filled), (110.0, 100.0), "{cmd}: the square moves whole");
let anchors = &s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors;
assert_eq!((anchors[0].p.x, anchors[1].p.x), (310.0, 350.0), "{cmd}: the selected anchor moves alone");
assert_eq!(s.journal.last().unwrap().0, cmd);
};
run(&mut s, p.as_ref());
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: off, the tiles stay");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(false), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "transformPatternTiles", json!(true));
run(&mut s, p.as_ref());
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "{cmd}: on, they move with the square");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
if let Some(mut p) = p {
p["patterns"] = json!(false);
run(&mut s, Some(&p));
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: the param wins");
s.execute("edit.undo", &json!({})).unwrap();
}
set_pref(&mut s, "transformPatternTiles", json!(false));
}
}
/// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the
/// swatch; on, only the same tint.
#[test]

View File

@ -1592,7 +1592,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
`object.align`, `object.distribute`, `object.distributeSpacing`, the Selection and transform tools, the dialogs'
Transform Patterns) and of the same param on the commands that move or duplicate art with an artboard (`artboard.move`
and the Artboard tool, `artboard.duplicate`, `artboard.rearrange`, `artboard.setProps`): pattern fills and strokes
move with the art.
move with the art. `object.nudge` (the arrow keys) and `path.moveAnchors` (a Direct Selection drag of selected
anchors) take the same param, with the same default, for the objects they move whole.
- `selectSameTintPercent` (off by default): `select.same.fillColor`, `strokeColor` and `fillAndStroke` take every tint
of a global or spot swatch; on, only the same tint.

File diff suppressed because one or more lines are too long