diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 617d9d944c..1e01c8f62e 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -4,12 +4,12 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::{Node, NodeKind, Scaling}; +use vectorcraft_doc::{Node, NodeKind, OrientedBox, Scaling}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::edit::{outermost, selected_roots}; use super::{ - CommandSpec, NodeId, Result, Session, always, bad, bool_or, check_numbers, check_switches, checked_ids_param, cmd, color_value, f64_or, + CommandSpec, NodeId, Result, Session, always, bad, bool_or, check_numbers, check_switches, checked_ids_param, cmd, color_value, f64_or, f64_req, find_command, finite_numbers, has_doc, index, }; use crate::EngineError; @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Free Distort", [], None, - "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays", + "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), angle?: degrees (counter-clockwise; `from`, or the selection bounds, is then a box in the frame turned by it, as a rotated bounding box is; default 0), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays", has_doc, distort ), @@ -62,11 +62,14 @@ pub fn specs() -> Vec { pub struct Projective { m: [f64; 8], src: Rect, + /// Page → the frame `src` is measured in. + pre: Affine, } impl Projective { - /// Map `src`'s corners (TL, TR, BR, BL) onto `q`. - pub fn from_rect(src: Rect, q: [Point; 4]) -> Self { + /// Map the corners (TL, TR, BR, BL) of `src`, a box that may be turned, onto the page points `q`. + pub fn from_box(bx: OrientedBox, q: [Point; 4]) -> Self { + let src = bx.rect; let [p0, p1, p2, p3] = q; let (dx1, dx2, dx3) = (p1.x - p2.x, p3.x - p2.x, p0.x - p1.x + p2.x - p3.x); let (dy1, dy2, dy3) = (p1.y - p2.y, p3.y - p2.y, p0.y - p1.y + p2.y - p3.y); @@ -80,11 +83,12 @@ impl Projective { let b = p3.x - p0.x + h * p3.x; let d = p1.y - p0.y + g * p1.y; let e = p3.y - p0.y + h * p3.y; - Self { m: [a, b, p0.x, d, e, p0.y, g, h], src } + Self { m: [a, b, p0.x, d, e, p0.y, g, h], src, pre: bx.to_doc().inverse() } } pub fn apply(&self, p: Point) -> Point { let [a, b, c, d, e, f, g, h] = self.m; + let p = self.pre * p; let u = if self.src.width().abs() > 1e-12 { (p.x - self.src.x0) / self.src.width() } else { 0.0 }; let v = if self.src.height().abs() > 1e-12 { (p.y - self.src.y0) / self.src.height() } else { 0.0 }; let w = g * u + h * v + 1.0; @@ -152,17 +156,29 @@ fn distort(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "nothing to distort")); } + let angle = p.get("angle").filter(|v| !v.is_null()).map(|_| f64_req(p, "angle", C)).transpose()?.map_or(0.0, vectorcraft_geom::normalize_deg); let src = match p.get("from").filter(|v| !v.is_null()) { - None => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?, + None if angle == 0.0 => OrientedBox::aligned(s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?), + None => { + // The selection's bounds square to the turned frame. + let doc = &s.doc()?.doc; + let (symbols, to_local) = (doc.symbol_boxes(), OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse()); + let rect = ids + .iter() + .filter_map(|id| doc.node(*id)) + .fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.bounds_in(&symbols, to_local, false))); + OrientedBox { angle, rect: rect.ok_or_else(|| bad(C, "nothing to distort"))? } + } Some(v) => { let [x0, y0, x1, y1] = finite_numbers(v).ok_or_else(|| bad(C, "from must be [x0, y0, x1, y1]"))?; - Rect::new(x0, y0, x1, y1) + OrientedBox { angle, rect: Rect::new(x0, y0, x1, y1) } } }; - if !src.width().is_finite() || !src.height().is_finite() || src.width().abs() < 1e-9 || src.height().abs() < 1e-9 { + let r = src.rect; + if !r.width().is_finite() || !r.height().is_finite() || r.width().abs() < 1e-9 || r.height().abs() < 1e-9 { return Err(bad(C, "cannot distort a zero-size or non-finite bounding box")); } - let pr = Projective::from_rect(src, [tl, tr, br, bl]); + let pr = Projective::from_box(src, [tl, tr, br, bl]); s.edit("Free Distort", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 0d9188243c..8907b21cc0 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -104,6 +104,92 @@ fn handles_turn_with_the_object_and_scale_along_its_axes() { assert!(near(selection_box(&s).rect.width(), 100.0)); } +/// Presses, drags and lets go with the current tool along `pts`, holding `mods`. +fn drag_with(s: &mut Session, pts: &[Point], mods: Mods) { + let v = ViewInfo::default(); + let kinds = std::iter::once(PointerKind::Down).chain(std::iter::repeat_n(PointerKind::Drag, pts.len().saturating_sub(2))); + for (kind, p) in kinds.chain([PointerKind::Up]).zip(pts) { + s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(mods), v).unwrap(); + } +} + +/// The anchors of path `id`, on the page. +fn anchors(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect() +} + +fn has_point(pts: &[Point], p: Point) -> bool { + pts.iter().any(|q| q.distance(p) < 1e-6) +} + +#[test] +fn free_transform_handles_turn_with_the_object_and_scale_along_its_axes() { + let mut s = session(); + let a = turned_rect(&mut s); + let b = selection_box(&s); + // The Right handle of the turned box, dragged 50 pt further out along the object's own x axis. + let right = b.to_doc() * Handle::Right.pos(b.rect); + let to = b.to_doc() * (Handle::Right.pos(b.rect) + vectorcraft_geom::Vec2::new(50.0, 0.0)); + let n = undo_len(&s); + s.select_tool("freeTransform", ViewInfo::default()).unwrap(); + drag_with(&mut s, &[right, to, to], Mods::default()); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Scale")); + // 150 × 50 along its own axes, still at exactly 45°, the left side where it was. + assert_eq!(angle(&s, a), b.angle); + let nb = selection_box(&s); + assert!(near(nb.rect.width(), 150.0) && near(nb.rect.height(), 50.0), "{nb:?}"); + let left = |b: &OrientedBox| b.to_doc() * Handle::Left.pos(b.rect); + assert!(left(&nb).distance(left(&b)) < 1e-6); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(selection_box(&s).rect.width(), 100.0)); +} + +#[test] +fn free_transform_shears_along_the_turned_side() { + let mut s = session(); + let a = turned_rect(&mut s); + let b = selection_box(&s); + let [tl, tr, br, bl] = b.corners(); + // Dragging the Top handle 20 pt along the object's own top side with Cmd held once the drag + // started (a Cmd press is the temporary Selection tool) slides that side along itself; the + // bottom side stays. + let top = b.to_doc() * Handle::Top.pos(b.rect); + let along = b.to_doc() * (Handle::Top.pos(b.rect) + vectorcraft_geom::Vec2::new(20.0, 0.0)); + let shift = along - top; + let v = ViewInfo::default(); + s.select_tool("freeTransform", v).unwrap(); + let cmd = Mods { cmd: true, ..Default::default() }; + for (kind, p, m) in [(PointerKind::Down, top, Mods::default()), (PointerKind::Drag, along, cmd), (PointerKind::Up, along, cmd)] { + s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(m), v).unwrap(); + } + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Shear")); + let pts = anchors(&s, a); + for p in [tl + shift, tr + shift, br, bl] { + assert!(has_point(&pts, p), "{p:?} not in {pts:?}"); + } +} + +#[test] +fn free_transform_distort_starts_from_the_turned_box() { + let mut s = session(); + let a = turned_rect(&mut s); + let b = selection_box(&s); + let [tl, tr, br, bl] = b.corners(); + s.select_tool("freeTransform", ViewInfo::default()).unwrap(); + s.set_tool_option("mode", &json!("distort")); + let to = br + vectorcraft_geom::Vec2::new(10.0, 20.0); + let n = undo_len(&s); + drag_with(&mut s, &[br, to, to], Mods::default()); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort")); + // The dragged corner of the turned box lands under the pointer; the other three stay. + let pts = anchors(&s, a); + for p in [tl, tr, to, bl] { + assert!(has_point(&pts, p), "{p:?} not in {pts:?}"); + } +} + #[test] fn transform_panel_sizes_follow_the_turned_box() { let mut s = session(); diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index 54c653e938..8e3830c81c 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -1190,6 +1190,32 @@ fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() { assert_eq!(undo_len(&s), history + 1); } +/// `object.distort` with `angle` reads `from` as a box in the frame turned by it, as the Free +/// Transform tool sends a turned bounding box: corners where that box already stands leave the art. +#[test] +fn distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let original = bounds(&s, a); + let bx = vectorcraft_doc::OrientedBox { angle: 90.0, rect: Rect::new(-100.0, 0.0, 0.0, 100.0) }; + let corners: Vec = bx.corners().iter().map(|c| json!([c.x, c.y])).collect(); + let history = undo_len(&s); + for p in [ + json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": "90", "ids": [a.0]}), + json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": [90], "ids": [a.0]}), + ] { + assert!(s.execute("object.distort", &p).is_err(), "invalid input: {p}"); + assert!(close(bounds(&s, a), original)); + assert_eq!(undo_len(&s), history); + } + s.execute("object.distort", &json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": 90, "ids": [a.0]})).unwrap(); + assert!(close(bounds(&s, a), original), "{:?}", bounds(&s, a)); + assert_eq!(undo_len(&s), history + 1); + // Without `from`, the box is the objects' bounds in the turned frame: the same box here. + s.execute("object.distort", &json!({"corners": corners, "angle": 90, "ids": [a.0]})).unwrap(); + assert!(close(bounds(&s, a), original), "{:?}", bounds(&s, a)); +} + /// `artboard.move` and `artboard.delete` take several artboards at once (#1139): one undo step /// each, the art on each moving with it, and one artboard always left. #[test] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index d0d624bbeb..05b81ae049 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1383,9 +1383,7 @@ pub(crate) fn release_guide_at(cx: &ToolContext, p: Point, m: Mods) -> Option Cursor { - let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 }; - let h = Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h); - match h { + match screen_handle(h, angle) { Handle::Top | Handle::Bottom => Cursor::ResizeV, Handle::Left | Handle::Right => Cursor::ResizeH, Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe, @@ -1393,6 +1391,13 @@ pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor { } } +/// The handle of an upright box that sits where handle `h` of a box turned by `angle` +/// (counter-clockwise degrees) appears, in 45° steps. +pub(crate) fn screen_handle(h: Handle, angle: f64) -> Handle { + let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 }; + Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h) +} + #[cfg(test)] mod tests; diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 0f111faea2..bd7c0c0ecd 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -4,15 +4,17 @@ //! Alt from centre), outside the corners rotates, inside moves, and Cmd-dragging a side handle //! shears along that side. A scale snaps as the Selection tool's does. The Perspective Distort and Free Distort modes (tool option `mode`, or //! Cmd / Cmd+Alt+Shift while dragging a corner like classic Illustrator) move the corners of the -//! box and preview `object.distort`. +//! box and preview `object.distort`. After a rotation the box stays turned with the selection, as +//! the Selection tool's does: its handles scale, shear and distort along the selection's own sides. use serde_json::{Value, json}; +use vectorcraft_doc::OrientedBox; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::{BLUE, polygon, rect_corners}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; use crate::guides::{PointSnap, Targets}; -use crate::select::{matrix_json, selection_bounds}; +use crate::select::{handle_cursor, matrix_json, screen_handle, selection_box}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -69,7 +71,8 @@ impl Op { #[derive(Clone, Copy, Debug)] struct Drag { op: Op, - rect: Rect, + /// The selection's bounding box at the press, turned with the selection. + bx: OrientedBox, start: Point, began: bool, } @@ -170,28 +173,37 @@ impl FreeTransformTool { } } - fn classify(&self, cx: &ToolContext, r: Rect, p: Point, m: Mods) -> Option { - let tol = cx.tol(5.0); - if let Some(h) = hit_handle(r, p, tol) { + /// What a press at `p` on box `bx` does, read in the box's own frame. + fn classify(&self, cx: &ToolContext, bx: &OrientedBox, p: Point, m: Mods) -> Option { + let (tol, r, lp) = (cx.tol(5.0), bx.rect, bx.to_local(p)); + if let Some(h) = hit_handle(r, lp, tol) { return Some(self.handle_op(h, m)); } - if self.mode == DistortMode::Free && in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() { + if self.mode == DistortMode::Free && in_rotate_zone(r, lp, tol, cx.tol(18.0)).is_some() { return Some(Op::Rotate); } - r.contains(p).then_some(Op::Move) + r.contains(lp).then_some(Op::Move) } fn preview(&mut self, cx: &ToolContext, d: Drag, p: Point, m: Mods) -> Action { let shift = m.shift || self.constrain; + let bx = d.bx; let delta = p - d.start; + // The drag along the box's own sides. + let local = bx.to_local(p) - bx.to_local(d.start); self.guides.clear(); let xf = match d.op { Op::Corners(h, mode) => { - let q = distort_quad(d.rect, h, delta, mode); + let q = distort_quad(bx.rect, h, local, mode).map(|c| bx.to_doc() * c); self.quad = Some(q); self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(delta.x, delta.y))); let corners: Vec = q.iter().map(|c| json!([c.x, c.y])).collect(); - return Action::Preview("object.distort".into(), json!({ "corners": corners, "from": [d.rect.x0, d.rect.y0, d.rect.x1, d.rect.y1] })); + let r = bx.rect; + let mut params = json!({ "corners": corners, "from": [r.x0, r.y0, r.x1, r.y1] }); + if bx.angle != 0.0 { + params["angle"] = json!(bx.angle); + } + return Action::Preview("object.distort".into(), params); } Op::Move => { let v = move_delta(d.start, p, shift, cx.constrain_angle); @@ -199,22 +211,23 @@ impl FreeTransformTool { Affine::translate(v) } Op::Scale(h) => { - let mut a = scale_for_drag(d.rect, h, p, shift, m.alt); + // Scaled in the box's own frame, then turned back onto the page. + let mut a = scale_for_drag(bx.rect, h, bx.to_local(p), shift, m.alt); if let Some(t) = &self.targets { - (a, self.guides) = t.snap_scale(cx, &vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt); + (a, self.guides) = t.snap_scale(cx, &bx, h, a, shift, m.alt); } - let nr = a.transform_rect_bbox(d.rect); + let nr = a.transform_rect_bbox(bx.rect); self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height()))); - a + bx.conjugate(a) } - Op::Shear(h) => shear_for_side(d.rect, h, delta, m.alt), + Op::Shear(h) => bx.conjugate(shear_for_side(bx.rect, h, local, m.alt)), Op::Rotate => { - let (a, deg) = rotate_for_drag(d.rect.center(), d.start, p, shift); + let (a, deg) = rotate_for_drag(bx.center(), d.start, p, shift); self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg))); a } }; - self.quad = Some(rect_corners(d.rect).map(|c| xf * c)); + self.quad = Some(bx.corners().map(|c| xf * c)); Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(xf), "copy": false })) } } @@ -233,10 +246,10 @@ impl Tool for FreeTransformTool { match ev.kind { PointerKind::Down => { self.drag = None; - let Some(r) = selection_bounds(cx) else { return vec![] }; - if let Some(op) = self.classify(cx, r, p, ev.mods) { + let Some(bx) = selection_box(cx) else { return vec![] }; + if let Some(op) = self.classify(cx, &bx, p, ev.mods) { self.targets = op.handle().is_some().then(|| PointSnap::new(cx, || Targets::for_scale(cx))); - self.drag = Some(Drag { op, rect: r, start: p, began: false }); + self.drag = Some(Drag { op, bx, start: p, began: false }); } vec![] } @@ -292,10 +305,12 @@ impl Tool for FreeTransformTool { fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = vec![]; - let quad = match (self.quad, selection_bounds(cx)) { - (Some(q), _) => q, - (None, Some(r)) => rect_corners(r), - _ => return o, + let quad = match self.quad { + Some(q) => q, + None => match selection_box(cx) { + Some(bx) => bx.corners(), + None => return o, + }, }; o.push(Overlay::Path { path: polygon(&quad, true), color: BLUE, width: 1.0, dashed: false }); for i in 0..4 { @@ -313,19 +328,17 @@ impl Tool for FreeTransformTool { } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { - let Some(r) = selection_bounds(cx) else { return Cursor::Arrow }; - match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) { - Some(Op::Rotate) => Cursor::rotate_about_in_view(r.center(), p, cx.view_angle()), + let Some(bx) = self.drag.map(|d| d.bx).or_else(|| selection_box(cx)) else { return Cursor::Arrow }; + // The box's angle as it shows on screen. + let shown = bx.angle - cx.view_angle(); + match self.drag.map(|d| d.op).or_else(|| self.classify(cx, &bx, p, m)) { + Some(Op::Rotate) => Cursor::rotate_about_in_view(bx.center(), p, cx.view_angle()), Some(Op::Move) => Cursor::Move, - Some(Op::Shear(Handle::Top | Handle::Bottom)) => Cursor::ShearH, - Some(Op::Shear(_)) => Cursor::ShearV, - Some(Op::Scale(h)) => match h { - Handle::Top | Handle::Bottom => Cursor::ResizeV, - Handle::Left | Handle::Right => Cursor::ResizeH, - Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe, - Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw, - } - .turned(cx.view_angle()), + Some(Op::Shear(h)) => match screen_handle(h, shown) { + Handle::Top | Handle::Bottom | Handle::TopLeft | Handle::BottomRight => Cursor::ShearH, + _ => Cursor::ShearV, + }, + Some(Op::Scale(h)) => handle_cursor(h, shown), Some(Op::Corners(..)) => Cursor::Crosshair, None => Cursor::Arrow, } @@ -422,6 +435,43 @@ mod tests { assert_eq!(t.cursor(&c, Point::new(207.0, 93.0), Mods::default()), Cursor::RotateToward(3), "out from the top right"); } + /// After a 30° turn the box, its handles and their cursors turn with the selection, and a side + /// handle scales along the selection's own width. + #[test] + fn the_box_and_its_handles_turn_with_the_selection() { + let (mut d, id) = doc_with_rect(); + let c = Vec2::new(150.0, 150.0); + let turn = Affine::translate(c) * Affine::rotate(-30f64.to_radians()) * Affine::translate(-c); + d.node_mut(id).unwrap().transform(turn, false); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let bx = selection_box(&cx).unwrap(); + assert!((bx.angle - 30.0).abs() < 1e-9, "{bx:?}"); + let mut t = FreeTransformTool::default(); + let shown: Vec = t.overlays(&cx).iter().filter_map(|o| if let Overlay::Anchor { p, .. } = o { Some(*p) } else { None }).collect(); + for corner in bx.corners() { + assert!(shown.iter().any(|q| q.distance(corner) < 1e-9), "{corner:?} not in {shown:?}"); + } + let top = bx.to_doc() * Handle::Top.pos(bx.rect); + assert_eq!(t.cursor(&cx, top, Mods::default()), Cursor::ResizeNwSe, "the top handle, turned 30°"); + // The Right handle dragged 100 pt out along the selection's own x axis doubles its width. + let right = bx.to_doc() * Handle::Right.pos(bx.rect); + let to = bx.to_doc() * (Handle::Right.pos(bx.rect) + Vec2::new(100.0, 0.0)); + t.pointer(&cx, &ev(PointerKind::Down, right.x, right.y)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y)); + assert_eq!(a[0], Action::Begin("Scale".into())); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + let m = Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]); + let local = bx.to_doc().inverse() * m * bx.to_doc(); + let want = Affine::translate((bx.rect.x0, 0.0)) * Affine::scale_non_uniform(2.0, 1.0) * Affine::translate((-bx.rect.x0, 0.0)); + for (got, want) in local.as_coeffs().iter().zip(want.as_coeffs()) { + assert!((got - want).abs() < 1e-9, "{local:?}"); + } + } + #[test] fn cmd_side_handle_shears() { let (d, id) = doc_with_rect(); diff --git a/docs/mcp.md b/docs/mcp.md index be3a5fb047..306c456e60 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4978,6 +4978,18 @@ used, as a transform's does. {"name":"run_command","arguments":{"command":"object.transformEach","params":{"scaleH":50,"scaleV":50,"strokes":false}}} ``` +## Free Distort on a turned bounding box + +`object.distort` maps the corners of `from` (TL, TR, BR, BL) onto `corners`, which are page coordinates. With +`angle` (counter-clockwise degrees), `from` is a box in the frame turned by that angle, as a rotated object's bounding +box is; without `from`, that box is the selection's bounds measured in the turned frame. An `angle` that is not a +number is an error. Without `angle`, `from` is a box on the page, as before. The Free Transform tool's Perspective Distort and Free Distort send the selection's turned box +this way, so after a rotation they start from the corners the canvas shows. + +```json +{"name":"run_command","arguments":{"command":"object.distort","params":{"from":[-100,0,0,100],"angle":90,"corners":[[0,100],[0,0],[120,-20],[100,100]]}}} +``` + ## Transform Each: Relative and Absolute `object.transformEach` scales by percentages of each object's size (the dialog's Relative, the default), or with diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 94d00df110..2441b076ae 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -857,6 +857,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Transform panel's Align to Pixel Grid checkbox (`object.setPixelAligned`) gives paths and compound paths a pixel-aligned property, saved in the native file: their horizontal and vertical straight segments sit on whole points (half points under an odd centered stroke) with whole stroke weights, and every move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor, move of a compound path's path on its own, artboard move, resize or copy that carries the art, Duplicate or paste that moves a copy, stroke edit and appearance edit that adds, removes, hides, repaints or reweighs a stroke snaps them again, while diagonal segments and curves keep their shape; a compound path made from an aligned path or compound path is aligned and snaps its paths with its own stroke; the box shows a dash for a mixed selection and is unavailable for type, images and groups ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::turning_it_on_snaps_the_path_in_one_undo_step`, `it_stays_aligned_through_transforms`, `it_stays_aligned_when_its_artboard_moves`, `diagonal_segments_and_curves_keep_their_shape`, `type_and_images_cannot_be_aligned`, `the_property_is_saved_and_read_back`, `stroke_edits_keep_it_aligned`, `a_compound_path_of_aligned_paths_is_aligned`, `a_compound_path_of_an_aligned_compound_path_is_aligned`, `a_stroke_without_paint_snaps_to_whole_points`, `moving_direct_selected_anchors_keeps_it_aligned`, `moving_one_anchor_keeps_it_aligned`, `moving_a_path_of_an_aligned_compound_path_keeps_it_aligned`, `artboard_options_and_artboard_copies_keep_it_aligned`, `appearance_edits_keep_it_aligned`, `placed_gradients_follow_the_snapped_path`, `duplicates_and_pastes_keep_it_aligned`, `paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned`, `the_art_of_a_linked_opacity_mask_keeps_it_aligned`, `a_linked_opacity_mask_follows_a_snap`, `aligning_direct_selected_anchors_keeps_it_aligned`, `global_edit_keeps_its_members_aligned`, `a_path_with_live_corners_snaps_its_inner_edges`, `geometry_edits_keep_it_aligned`, `other_path_edits_keep_it_aligned`, `panels::transform::tests::align_to_pixel_grid_shows_and_sets_the_property`). - Align New Objects to Pixel Grid (`document.setup {alignNewObjectsToPixelGrid}`, a checkbox in New Document's Advanced Options and a checked item in the Transform panel menu) gives every path and compound path drawn in the document, the paths of a new grid or flare included, the pixel-aligned property, snapped in the step that draws it; it is on in the Mobile and Web presets and off in the others, saved presets and Recent keep it, and the document saves it. Type, images and groups do not take it, and art already in the document, pasted, duplicated or placed keeps its own property (VectorCraft's own rules) ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::align_new_objects_makes_new_paths_pixel_aligned`, `align_new_objects_reaches_grids_flares_and_add_on_shapes`, `align_new_objects_is_saved_and_read_back`, `tests::newdoc::align_new_objects_to_pixel_grid_follows_the_preset`, `panels::transform::tests::align_new_objects_menu_item_toggles_the_active_document`, `dialogs::new_document::tests::align_new_objects_checkbox_follows_the_preset_and_reaches_the_document`). - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). + - After a rotation the Free Transform tool shows and drags the same turned bounding box as the Selection tool: its handles, their cursors and its rotate zone sit on the selection's own sides, a handle scales along those sides from the opposite handle ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html)), Cmd/Ctrl on a side handle shears along that side, and Perspective Distort and Free Distort start from the turned box's corners (`object.distort` takes `angle` with `from`). The Scale and Reflect tools, their dialogs and commands, and the Transform panel's flips keep the page's axes (VectorCraft's own rule; `bboxrotate::free_transform_handles_turn_with_the_object_and_scale_along_its_axes`, `free_transform_shears_along_the_turned_side`, `free_transform_distort_starts_from_the_turned_box`, `xform::distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle`, tools `xform::free::tests::the_box_and_its_handles_turn_with_the_selection`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`).