From b00e50d4c57348241e15f86dc6dd0c159fa261a2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:30:48 -0800 Subject: [PATCH] Transform: pattern-only Scale and Reflect of selected anchors follow the box With anchors selected and Transform Objects off (objects: false, or the backquote key during a Scale or Reflect drag), the transform moves the whole objects' pattern tiles, so it now acts along the turned bounding box instead of the page axes that apply to the anchors alone. --- crates/engine/src/cmd/object/transform.rs | 8 +++--- crates/engine/src/tests/bboxrotate.rs | 29 ++++++++++++++++++++ crates/tools/src/xform/transform.rs | 32 ++++++++++++++++------- docs/mcp.md | 3 ++- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 1eac08e571..e7a2961285 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -365,12 +365,14 @@ pub(super) fn scale(s: &mut Session, p: &Value) -> Result { /// The frame that `object.scale` and a named `object.reflect` act in, from the `basis` param: /// `"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 (and no copy) 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. +/// 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. fn basis_frame(s: &mut Session, p: &Value, cmd: &str, ids: &[NodeId]) -> Result { let page = choice(p, cmd, "basis", &["box", "page"])? == Some("page"); let st = s.doc()?; - let anchors = !bool_or(p, "copy", false) && effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()).is_some(); + let anchors = !bool_or(p, "copy", false) + && transforms_objects(p) + && effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()).is_some(); let angle = if page || anchors { 0.0 } else { st.doc.bbox_angle(ids) }; s.note_journal("basis", json!(if page { "page" } else { "box" })); Ok(OrientedBox { angle, rect: Rect::ZERO }) diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 0a6df22e10..3721e332d3 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -469,6 +469,35 @@ fn scale_follows_the_page_after_a_reset_and_for_mixed_angles() { assert!(near(r.width(), w.width()) && near(r.height(), 2.0 * w.height()), "{w:?} {r:?}"); } +/// With anchors selected, Scale and a named Reflect move the anchors along the page's axes, but +/// with Transform Objects off they move the whole objects' pattern tiles, along the turned box. +#[test] +fn pattern_only_transforms_of_selected_anchors_follow_the_turned_box() { + let c = Point::new(200.0, 200.0); + let frame = OrientedBox { angle: 45.0, rect: Rect::ZERO }; + let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2()); + let last = |s: &Session| match s.doc().unwrap().last_transform.clone() { + Some(LastTransform::Matrix(m, _)) => m, + other => panic!("no matrix to repeat: {other:?}"), + }; + let scale = Affine::scale_non_uniform(2.0, 1.0); + let flip = Affine::scale_non_uniform(-1.0, 1.0); + for (cmd, p, m) in [("object.scale", json!({"sx": 200, "sy": 100}), scale), ("object.reflect", json!({"axis": "vertical"}), flip)] { + for objects in [false, true] { + let mut s = session(); + let a = turned_rect(&mut s); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + let mut p = p.clone(); + p["origin"] = json!([c.x, c.y]); + p["objects"] = json!(objects); + p["patterns"] = json!(true); + s.execute(cmd, &p).unwrap(); + let want = if objects { about(m) } else { about(frame.conjugate(m)) }; + assert!(same_matrix(last(&s), want), "{cmd} objects {objects}: {:?}", last(&s)); + } + } +} + #[test] fn groups_type_and_multiple_selections_keep_their_angle() { let mut s = session(); diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 8f3f3b1768..47cd5b178a 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -74,7 +74,8 @@ struct Drag { /// ([`TransformTool::matrix_in`]). angle: f64, /// Anchors are selected with Direct Selection: they transform alone along the page's axes, - /// and a copy transforms the whole objects along the box ([`Drag::angle_for`]). + /// and a copy or a pattern-only drag transforms the whole objects along the box + /// ([`Drag::angle_for`]). anchors: bool, /// The snapped pointer and Shift at the last drag event, to work out the transform again /// when the Alt state at the release changes what it transforms. @@ -84,9 +85,10 @@ struct Drag { } impl Drag { - /// The angle Scale and Reflect act along, for a drag that copies (`copy`) or not. - fn angle_for(&self, copy: bool) -> f64 { - if self.anchors && !copy { 0.0 } else { self.angle } + /// The angle Scale and Reflect act along, for a drag that copies (`copy`) or not, and that + /// transforms only pattern tiles (`patterns_only`, the backquote key) or not. + fn angle_for(&self, copy: bool, patterns_only: bool) -> f64 { + if self.anchors && !copy && !patterns_only { 0.0 } else { self.angle } } } @@ -292,7 +294,7 @@ impl Tool for TransformTool { } let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p)); self.hover = guides; - let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt), d.origin, d.pick, p, ev.mods.shift, d.bounds); + let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, p, ev.mods.shift, d.bounds); (d.last, d.pointer, d.shift) = (m, p, ev.mods.shift); self.drag = Some(d); self.measure = Some((p, text)); @@ -302,10 +304,14 @@ impl Tool for TransformTool { 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, and with - // anchors selected, whether it acts along the box or the page. - let m = - if d.anchors { self.matrix_in(d.angle_for(ev.mods.alt), d.origin, d.pick, d.pointer, d.shift, d.bounds).0 } else { d.last }; + // The Alt and backquote states at release decide whether the result is a copy + // or moves only pattern tiles, and with anchors selected, whether it acts + // along the box or the page. + let m = if d.anchors { + self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, d.pointer, d.shift, d.bounds).0 + } else { + d.last + }; vec![preview(m, ev.mods.alt, ev.backquote), Action::Commit] } else { // A click sets the reference point; with Alt, it opens the dialog too. @@ -680,6 +686,14 @@ mod tests { let up = t.pointer(&cx, &ev(PointerKind::Up, to.x, to.y).with_mods(if copy { alt() } else { Mods::default() })); assert_eq!(page_axes(matrix(&up[0])), !copy, "copy {copy}: {up:?}"); } + // With the backquote key the drag moves only the whole objects' pattern tiles, which + // scale along the box, both while dragging and at the release. + let mut t = TransformTool::new(TransformKind::Scale); + t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y)); + let a = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, to.x, to.y) }); + assert!(!page_axes(matrix(&a[1])), "{a:?}"); + let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, to.x, to.y) }); + assert!(!page_axes(matrix(&up[0])), "{up:?}"); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index b5434c6abc..ffb534472e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5144,7 +5144,8 @@ stretches along the box's own width and `sy` along its height, and `"vertical"` axes, until Object › Transform › Reset Bounding Box squares the box to the page. Both take `basis?: "box"|"page"`: `"box"` is the default, and `"page"` acts along the page's axes. Any other `basis` is an error, and nothing changes. `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. The journal entry +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 and Reflect dialogs, the Transform and Properties panels' flips, the Scale tool and the Reflect tool's default axis