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