Part of #394: Align and Distribute move pattern tiles when Transform Pattern Tiles is on

object.align, object.distribute and object.distributeSpacing moved
objects without their pattern tiles, whatever General › Transform
Pattern Tiles was set to. They now follow the preference as Move,
Nudge and the Selection tool do: with it on, pattern fills and strokes
move with the art. Each also takes a `patterns` param for one call, as
the transforms do. When the art uses a pattern, the journal records the
choice, and a recorded action plays back with it.

Docs: docs/mcp.md (transformPatternTiles), docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-11 11:33:05 +07:00
committed by bflatastic
parent d10962ac45
commit a31cdab606
4 changed files with 57 additions and 9 deletions
+9 -6
View File
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_selection,
align
),
@@ -168,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_multi,
distribute
),
@@ -177,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute Spacing",
["Window", "Align"],
None,
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_multi,
distribute_spacing
),
@@ -1048,6 +1048,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect()
};
let sc = Scaling { patterns: transform_patterns(s, p, &ids)?, ..Scaling::default() };
// When nothing moves, the document and its undo history stay as they are.
if moves_nothing(&moves) {
return ok();
@@ -1055,7 +1056,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Align", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
n.transform(Affine::translate(*dv), sc);
}
}
Ok(())
@@ -1102,6 +1103,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
})
.collect();
let sc = Scaling { patterns: transform_patterns(s, p, &ids)?, ..Scaling::default() };
// When nothing moves, the document and its undo history stay as they are.
if moves_nothing(&moves) {
return ok();
@@ -1109,7 +1111,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Distribute", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
n.transform(Affine::translate(*dv), sc);
}
}
Ok(())
@@ -1153,6 +1155,7 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
let moves: Vec<(NodeId, Vec2)> =
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
let sc = Scaling { patterns: transform_patterns(s, p, &ids)?, ..Scaling::default() };
// When nothing moves, the document and its undo history stay as they are.
if moves_nothing(&moves) {
return ok();
@@ -1160,7 +1163,7 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Distribute Spacing", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
n.transform(Affine::translate(*dv), sc);
}
}
Ok(())
+44
View File
@@ -452,6 +452,50 @@ fn transform_pattern_tiles_is_the_transforms_default() {
assert_eq!(tiles(&s), [0.5, 0.0, 0.0, 0.5, 5.0, 0.0]);
}
/// General › Transform Pattern Tiles (#394) applies to Align and Distribute as it does to the
/// transforms: off, the tiles of a pattern-filled square stay in place when the square moves;
/// on, they move with it, and `patterns: false` keeps them in place for one call.
#[test]
fn align_and_distribute_follow_transform_pattern_tiles() {
use vectorcraft_color::Paint;
let mut s = new_doc();
let plain = square(&mut s, 0.0, 0.0);
s.execute("select.set", &json!({"ids": [plain.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
let far = square(&mut s, 300.0, 0.0);
let filled = square(&mut s, 100.0, 0.0);
s.execute("paint.setFill", &json!({"ids": [filled.0], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [plain.0, filled.0, far.0]})).unwrap();
let tiles = |s: &Session| match s.doc().unwrap().doc.node(filled).unwrap().appearance.fill_paint() {
Paint::Pattern { xf, .. } => xf.as_coeffs(),
p => panic!("not a pattern: {p:?}"),
};
// Align left moves the square 100 pt left, to the left edge of `plain`. Both Distribute
// commands move it 50 pt right, halfway between the other two.
for (cmd, p, dx) in [
("object.align", json!({"horizontal": "left"}), -100.0),
("object.distribute", json!({"horizontal": "left"}), 50.0),
("object.distributeSpacing", json!({"axis": "horizontal"}), 50.0),
] {
s.execute(cmd, &p).unwrap();
assert_eq!(tiles(&s), [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));
s.execute(cmd, &p).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, dx, 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();
let mut p = p;
p["patterns"] = json!(false);
s.execute(cmd, &p).unwrap();
assert_eq!(tiles(&s), [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]