Transform: saved Scale and Reflect steps without a basis keep the page's axes

An action or journal entry recorded before Scale and Reflect followed the
turned bounding box has no basis value. Replayed, such a step now acts along
the page's axes, as it did when it was recorded; steps recorded now carry
basis "box" and replay along the box. The built-in Reflect Horizontal action
names basis "box", so it keeps reflecting across the box's own axis.

The symbol test that reflects a turned instance names basis "page", which
is the reflection it was written for.
This commit is contained in:
dotCooCoo
2026-11-14 16:30:56 -08:00
parent 93a7d532c7
commit c7aa27ec12
5 changed files with 72 additions and 8 deletions
+2 -2
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Scale…",
["Object", "Transform"],
None,
"{ids?: [id…] (selected first; default: the selection), sx: % (along the bounding box's width), sy?: % (along its height), origin?: [x,y] (a page point), basis?: \"box\"|\"page\" (box, the default: sx and sy follow the bounding box, turned with rotated objects until Reset Bounding Box; page: the page's axes), 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), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (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.",
"{ids?: [id…] (selected first; default: the selection), sx: % (along the bounding box's width), sy?: % (along its height), origin?: [x,y] (a page point), basis?: \"box\"|\"page\" (box, the default: sx and sy follow the bounding box, turned with rotated objects until Reset Bounding Box; page: the page's axes; a saved step without basis, recorded before it, plays along the page's axes), 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), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (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
),
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reflect…",
["Object", "Transform"],
None,
"{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical; vertical and horizontal are the bounding box's own axes, deg a line on the page), origin?, basis?: \"box\"|\"page\" (for a named axis; box, the default: the bounding box's axes, turned with rotated objects until Reset Bounding Box; page: the page's axes), copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
"{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical; vertical and horizontal are the bounding box's own axes, deg a line on the page), origin?, basis?: \"box\"|\"page\" (for a named axis; box, the default: the bounding box's axes, turned with rotated objects until Reset Bounding Box; page: the page's axes; a saved step without basis, recorded before it, plays along the page's axes), copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. 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
),
+5 -2
View File
@@ -366,9 +366,12 @@ pub(super) fn scale(s: &mut Session, p: &Value) -> Result<Value> {
/// `"box"` (the default) is the bounding box of `ids`, turned with rotated objects until Reset
/// Bounding Box ([`Document::bbox_angle`]); `"page"` is the page's axes. Anchors selected with
/// Direct Selection (with no copy and Transform Objects on) transform along the page's axes, as
/// their own box does. The basis joins the journal entry, so a replay acts in the same frame.
/// their own box does. The basis joins the journal entry, so a replay acts in the same frame. A
/// saved step without `basis` ([`Session::replay`]) was recorded before Scale and Reflect followed
/// the box, so it acts along the page's axes, as it did when it was recorded.
fn basis_frame(s: &mut Session, p: &Value, cmd: &str, ids: &[NodeId]) -> Result<OrientedBox> {
let page = choice(p, cmd, "basis", &["box", "page"])? == Some("page");
let basis = choice(p, cmd, "basis", &["box", "page"])?;
let page = basis == Some("page") || (basis.is_none() && s.replaying());
let st = s.doc()?;
let anchors = !bool_or(p, "copy", false)
&& transforms_objects(p)
+40 -1
View File
@@ -666,7 +666,7 @@ fn expanding_a_reflected_symbol_instance_keeps_its_box() {
for reset in [false, true] {
let (mut s, inst) = turned_instance(0.0);
s.execute("object.rotate", &json!({"angle": 150})).unwrap();
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
s.execute("object.reflect", &json!({"axis": "horizontal", "basis": "page"})).unwrap();
assert!(near(angle(&s, inst), -170.0), "{}", angle(&s, inst));
if reset {
s.execute("object.resetBoundingBox", &json!({})).unwrap();
@@ -786,6 +786,45 @@ fn scale_and_reflect_follow_the_turned_box() {
assert!([got.x0 - want.x0, got.y0 - want.y0, got.x1 - want.x1, got.y1 - want.y1].iter().all(|d| d.abs() < 1e-6), "{got:?} {want:?}");
}
/// A saved step without `basis` was recorded before Scale and Reflect followed the turned box, so a
/// replay of it acts along the page's axes, as it did then, and its new journal entry says so. A
/// step recorded now names its basis and replays along the box. A call without `basis` that is not
/// a replay acts along the box.
#[test]
fn saved_steps_without_a_basis_replay_on_the_page() {
// The turned rectangle after `run` runs on it: its page bounds and its box's angle.
let after = |run: &dyn Fn(&mut Session)| {
let mut s = session();
let a = turned_rect(&mut s);
run(&mut s);
let st = (s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(), angle(&s, a));
(st, s.journal.last().unwrap().clone())
};
let same = |(r, a): (Rect, f64), (q, b): (Rect, f64)| [r.x0 - q.x0, r.y0 - q.y0, r.x1 - q.x1, r.y1 - q.y1, a - b].iter().all(|d| d.abs() < 1e-6);
for (id, p) in [("object.scale", json!({"sx": 200, "sy": 100})), ("object.reflect", json!({"axis": "vertical"}))] {
let mut on_page = p.clone();
on_page["basis"] = json!("page");
let (page, _) = after(&|s| {
s.execute(id, &on_page).unwrap();
});
let (old, (_, noted)) = after(&|s| {
s.replay(id, &p).unwrap();
});
assert!(same(old, page), "{id}: {old:?} {page:?}");
assert_eq!(noted["basis"], "page", "{id}");
let (boxed, (recorded_id, recorded)) = after(&|s| {
s.execute(id, &p).unwrap();
});
assert!(!same(boxed, page) && near(boxed.1, 45.0), "{id}: {boxed:?}");
assert_eq!(recorded["basis"], "box", "{id}");
let (replayed, _) = after(&|s| {
s.replay(&recorded_id, &recorded).unwrap();
});
assert!(same(replayed, boxed), "{id}: {replayed:?} {boxed:?}");
}
}
/// Once Reset Bounding Box squares the box, and for objects standing at different angles, Scale
/// acts along the page's axes.
#[test]
+23 -1
View File
@@ -312,7 +312,7 @@ pub fn default_sets() -> Vec<ActionSet> {
Action::new("Center on Artboard", vec![step("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard"}))]),
Action::new("Outline Text", vec![step("type.createOutlines", json!({}))]),
Action::new("Rotate 90° CW", vec![step("object.rotate", json!({"angle": -90}))]),
Action::new("Reflect Horizontal", vec![step("object.reflect", json!({"axis": "vertical"}))]),
Action::new("Reflect Horizontal", vec![step("object.reflect", json!({"axis": "vertical", "basis": "box"}))]),
],
)]
}
@@ -2721,6 +2721,28 @@ mod tests {
}
}
/// The built-in Reflect Horizontal reflects a rotated object across its bounding box's own axis,
/// as the Reflect dialog does, so the box keeps its angle; a saved step without a basis, recorded
/// before Reflect followed the box, still reflects across the page's axis.
#[test]
fn the_built_in_reflect_follows_the_turned_box() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
let id = vectorcraft_doc::NodeId(
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].as_u64().unwrap(),
);
app.run("select.set", json!({"ids": [id.0]})).unwrap();
app.run("object.rotate", json!({"angle": 30})).unwrap();
let angle = |app: &VectorcraftApp| app.session.active().unwrap().doc.bbox_angle(&[id]);
let reflect = default_sets()[0].actions.iter().position(|a| a.name == "Reflect Horizontal").unwrap();
app.ui.action_sets = default_sets();
play(&mut app, 0, reflect).unwrap();
assert!((angle(&app) - 30.0).abs() < 1e-6, "{}", angle(&app));
app.ui.action_sets = vec![ActionSet::new("Old", vec![Action::new("Reflect", vec![("object.reflect".into(), json!({"axis": "vertical"}))])])];
play(&mut app, 0, 0).unwrap();
assert!((angle(&app) + 30.0).abs() < 1e-6, "{}", angle(&app));
}
/// An action recorded with a key its command no longer reads still plays, accelerated and one
/// step at a time, though the same command run by itself is refused.
#[test]
+2 -2
View File
@@ -5635,8 +5635,8 @@ axes, until Object › Transform › Reset Bounding Box squares the box to the p
`origin` is still a page point, and a numeric `axis` is still a line on the page at that angle. Selections whose
objects stand at different angles, and anchors selected with Direct Selection, use the page's axes; with `copy` or
with `objects: false`, the whole objects or their pattern tiles transform, along the box. The journal entry
records the `basis` used, so a recorded action replays in the same frame; an older recording without `basis` acts
along the box. `object.transformAgain` repeats the transform's page matrix, also after Reset Bounding Box. The Scale
records the `basis` used, so a recorded action replays in the same frame; a saved step without `basis` (an action or
journal entry recorded before it) plays along the page's axes, as it did when it was recorded. `object.transformAgain` repeats the transform's page matrix, also after Reset Bounding Box. The Scale
and Reflect dialogs, the Transform and Properties panels' flips, the Scale tool and the Reflect tool's default axis
and Shift steps follow the box the same way; a Reflect axis drawn with the tool is the line drawn on the page.