From 291afd8aa6f5d5794dc4c985a32ece83488ef9dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:07:21 -0800 Subject: [PATCH] Free Distort: the bounding box turns with the warped art object.distort warped the anchors of paths, groups, compound paths and clip groups but left their bounding-box angle as it was, so a rotated path kept its old box after a warp and an upright one stayed square to the page however far the warp tilted it. Text, images and symbol instances already took the angle of the warp's affine approximation. - Each path, group, compound path and clip group now takes the angle the warp turns its x axis to at its own center before the warp, the members of a warped group and the art of a linked opacity mask each at their own. An unlinked mask's art keeps its angle. Mirroring keeps the nearer angle and a collapsing warp keeps the old one. Reset Bounding Box squares the boxes again. - Each subtree's bounds are measured once while it is warped. - Corners that equal the corners of the box being warped change nothing and add no undo step. Docs: object.distort params text, docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/xform.rs | 51 ++++-- crates/engine/src/tests/bboxrotate.rs | 223 +++++++++++++++++++++++++- crates/engine/src/tests/xform.rs | 21 ++- docs/mcp.md | 10 +- docs/target-app-parity.md | 1 + 5 files changed, 292 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 1e01c8f62e..0fc5340e2f 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -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), 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", + "{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; each warped object's bounding box (the objects inside groups and the art of linked masks too) turns as the warp turns the art at its center (object.resetBoundingBox squares it again); corners where the box already stands change nothing and add no undo step", has_doc, distort ), @@ -109,11 +109,18 @@ impl Projective { /// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine /// approximation at their center. The art of a linked opacity mask is warped with the object it -/// masks; an unlinked one stays where it is. -fn warp_node(n: &mut Node, pr: &Projective) { +/// masks; an unlinked one stays where it is. Each warped object's bounding-box angle follows the +/// warp's affine approximation at that object's own center before the warp (as for the objects +/// with no editable points); an object with no bounds keeps its angle, and a layer has none. +/// Returns `n`'s geometric bounds before the warp ([`Node::geometric_bounds`]; none for a layer), +/// gathered from its children's so that each subtree is measured once. +fn warp_node(n: &mut Node, pr: &Projective) -> Option { n.pin_gradients(); - match &mut n.kind { + // The children before these are left out of a group's or compound path's bounds. + let skip = n.children().map_or(0, Vec::len) - n.editing_children().len(); + let before = match &mut n.kind { NodeKind::Path { path, live, .. } => { + let b = path.bounds(); *live = None; for sp in &mut path.subpaths { for a in &mut sp.anchors { @@ -122,24 +129,41 @@ fn warp_node(n: &mut Node, pr: &Projective) { a.h_out = pr.apply(a.h_out); } } + b } - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { - for c in children.iter_mut() { - warp_node(Arc::make_mut(c), pr); - } + NodeKind::Layer { children, .. } => { + warp_children(children, pr, |_| false); + None } + // A clip group is bounded by its clip path. + NodeKind::Group { children, clip: true } => warp_children(children, pr, |i| i == 0), + NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => warp_children(children, pr, |i| i >= skip), _ => { // No editable points: the affine approximation (which maps the gradients too). - if let Some(b) = n.geometric_bounds() { + let b = n.geometric_bounds(); + if let Some(b) = b { n.transform(pr.affine_near(b.center()), false); } - return; + return b; } + }; + if let Some(b) = before { + n.bbox_angle = vectorcraft_doc::orient::transformed_angle(n.bbox_angle, pr.affine_near(b.center())); } if let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) { warp_node(Arc::make_mut(&mut m.art), pr); } n.appearance.warp_gradients(&|p| pr.affine_near(p)); + before +} + +/// Warps each of `children` and returns the union of the pre-warp bounds of those `counts` picks +/// by index. +fn warp_children(children: &mut [Arc], pr: &Projective, counts: impl Fn(usize) -> bool) -> Option { + children.iter_mut().enumerate().fold(None, |acc, (i, c)| { + let b = warp_node(Arc::make_mut(c), pr); + if counts(i) { vectorcraft_geom::union_opt(acc, b) } else { acc } + }) } fn distort(s: &mut Session, p: &Value) -> Result { @@ -178,6 +202,11 @@ fn distort(s: &mut Session, p: &Value) -> Result { 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 ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::>() }); + // Corners that stay where they are change nothing: no edit and no undo step. + if [tl, tr, br, bl] == src.corners() { + return Ok(ids_json); + } let pr = Projective::from_box(src, [tl, tr, br, bl]); s.edit("Free Distort", |d, _| { for id in &ids { @@ -187,7 +216,7 @@ fn distort(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; - Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) + Ok(ids_json) } // ---------- eyedropper ---------- diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 8907b21cc0..aa1c14885c 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Node, OrientedBox}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_tools::bbox::Handle; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -190,6 +190,227 @@ fn free_transform_distort_starts_from_the_turned_box() { } } +/// Where the line through `a` and `b` crosses the line through `c` and `d`. +fn cross(a: Point, b: Point, c: Point, d: Point) -> Point { + let (r, q) = (b - a, d - c); + let t = ((c - a).x * q.y - (c - a).y * q.x) / (r.x * q.y - r.y * q.x); + a + r * t +} + +/// The counter-clockwise angle of the direction from `from` to `to`. +fn direction(from: Point, to: Point) -> f64 { + -(to.y - from.y).atan2(to.x - from.x).to_degrees() +} + +fn near_deg(a: f64, b: f64) -> bool { + vectorcraft_geom::normalize_deg(a - b).abs() < 1e-6 +} + +fn json_points(pts: [Point; 4]) -> Value { + json!(pts.map(|p| [p.x, p.y])) +} + +/// `pts` turned by `deg` (counter-clockwise) about `c`. +fn turned(pts: [Point; 4], c: Point, deg: f64) -> [Point; 4] { + let a = Affine::translate(c.to_vec2()) * Affine::rotate(-deg.to_radians()) * Affine::translate(-c.to_vec2()); + pts.map(|p| a * p) +} + +/// A perspective quad for the 100 × 100 box at the origin: its top-right corner raised 20 pt. +const RAISED: [(f64, f64); 4] = [(0.0, 0.0), (100.0, -20.0), (100.0, 100.0), (0.0, 100.0)]; + +fn raised() -> [Point; 4] { + RAISED.map(|(x, y)| Point::new(x, y)) +} + +/// The angle a warp onto `quad` (from a box square to the page) turns an upright object's x axis +/// to, where the object's center lands at `center`: every horizontal line of the box is drawn +/// through the point where the lines of the quad's top and bottom sides cross. +fn upright_angle_after(quad: [Point; 4], center: Point) -> f64 { + direction(cross(quad[0], quad[1], quad[3], quad[2]), center) +} + +/// Where a warped rectangle's center lands: where the diagonals of its warped corners cross. +fn warped_center(s: &Session, id: NodeId) -> Point { + let p = anchors(s, id); + assert_eq!(p.len(), 4, "{p:?}"); + cross(p[0], p[2], p[1], p[3]) +} + +#[test] +fn distort_turns_an_upright_box_with_the_warp_at_its_center() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + let quad = raised(); + s.execute("object.distort", &json!({ "corners": json_points(quad) })).unwrap(); + // The top tilts up to the right: a small counter-clockwise angle (atan 0.1 for this quad). + let want = upright_angle_after(quad, cross(quad[0], quad[2], quad[1], quad[3])); + assert!(near(want, 0.1_f64.atan().to_degrees()), "{want}"); + assert!(near(angle(&s, a), want), "{} vs {want}", angle(&s, a)); + // The Selection and Free Transform tools' box stands at it. + assert!(near(selection_box(&s).angle, want), "{:?}", selection_box(&s)); + // Reset Bounding Box squares it to the page and leaves the art. + let pts = anchors(&s, a); + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1); + assert_eq!(angle(&s, a), 0.0); + assert_eq!(anchors(&s, a), pts); + + // A warp that only stretches a turned object along its own axes keeps its angle exactly. + let mut s = session(); + let t = turned_rect(&mut s); + let b = selection_box(&s); + let wide = OrientedBox { angle: b.angle, rect: Rect::new(b.rect.x0, b.rect.y0, b.rect.x1 + 50.0, b.rect.y1) }; + let r = b.rect; + s.execute("object.distort", &json!({"corners": json_points(wide.corners()), "from": [r.x0, r.y0, r.x1, r.y1], "angle": b.angle})).unwrap(); + assert_eq!(angle(&s, t), b.angle); + assert!(near(selection_box(&s).rect.width(), 150.0), "{:?}", selection_box(&s)); +} + +/// Every object's angle in `n`'s subtree, opacity mask art included, each with whether it is the +/// art of an unlinked mask (or inside one). +fn angles_with_links(n: &Node, unlinked: bool, out: &mut Vec<(f64, bool)>) { + out.push((n.bbox_angle, unlinked)); + for c in n.children().into_iter().flatten() { + angles_with_links(c, unlinked, out); + } + if let Some(m) = &n.extras().mask { + angles_with_links(&m.art, unlinked || !m.linked, out); + } +} + +fn group_angles(s: &Session, g: NodeId) -> Vec<(f64, bool)> { + let mut out = vec![]; + angles_with_links(s.doc().unwrap().doc.node(g).unwrap(), false, &mut out); + out +} + +fn selected(s: &Session) -> NodeId { + s.doc().unwrap().selection.objects[0] +} + +#[test] +fn distort_turns_each_member_and_linked_mask_by_its_own_warp() { + let mut s = session(); + // A path turned 20° with a linked mask turned with it. + let a = rect(&mut s, 100.0, 100.0, 60.0, 40.0); + let am = rect(&mut s, 100.0, 100.0, 60.0, 40.0); + sel(&mut s, &[a, am]); + s.execute("object.rotate", &json!({"angle": 20})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + // A path with an unlinked mask. + let u = rect(&mut s, 200.0, 100.0, 40.0, 40.0); + let um = rect(&mut s, 200.0, 100.0, 40.0, 40.0); + sel(&mut s, &[u, um]); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap(); + // A compound path turned 10°. + let c1 = rect(&mut s, 100.0, 200.0, 40.0, 40.0); + let c2 = rect(&mut s, 120.0, 220.0, 40.0, 40.0); + sel(&mut s, &[c1, c2]); + s.execute("object.compoundPath.make", &json!({})).unwrap(); + let compound = selected(&s); + s.execute("object.rotate", &json!({"angle": 10})).unwrap(); + // A clip group. + let k1 = rect(&mut s, 250.0, 200.0, 50.0, 50.0); + let k2 = rect(&mut s, 260.0, 210.0, 50.0, 50.0); + sel(&mut s, &[k1, k2]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let clip = selected(&s); + sel(&mut s, &[a, u, compound, clip]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + let before = group_angles(&s, g); + assert!(before.len() >= 10 && before.iter().any(|(a, _)| near(*a, 20.0)) && before.iter().any(|(_, u)| *u), "{before:?}"); + // A warp that turns the group 30° about its center turns every object 30° more, except the + // art of the unlinked mask, which stays. + let b = s.doc().unwrap().doc.bounds_of(&[g], false).unwrap(); + let n = undo_len(&s); + s.execute("object.distort", &json!({ "corners": json_points(turned(OrientedBox::aligned(b).corners(), b.center(), 30.0)) })).unwrap(); + assert_eq!(undo_len(&s), n + 1, "one step"); + let after = group_angles(&s, g); + assert_eq!(after.len(), before.len()); + for ((old, unlinked), (new, _)) in before.iter().zip(&after) { + if *unlinked { + assert_eq!(new, old, "{before:?} → {after:?}"); + } else { + assert!(near_deg(*new, old + 30.0), "{before:?} → {after:?}"); + } + } + + // Under a perspective warp each object follows the warp at its own center: two members of a + // group, one above the other, tilt by different amounts, and the group by the warp at its own + // center. + let mut s = session(); + let p = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + let q = rect(&mut s, 0.0, 50.0, 100.0, 50.0); + sel(&mut s, &[p, q]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + let quad = raised(); + s.execute("object.distort", &json!({ "corners": json_points(quad) })).unwrap(); + let (want_p, want_q) = (upright_angle_after(quad, warped_center(&s, p)), upright_angle_after(quad, warped_center(&s, q))); + assert!(near(angle(&s, p), want_p) && near(angle(&s, q), want_q), "{} {} vs {want_p} {want_q}", angle(&s, p), angle(&s, q)); + assert!(want_p > want_q + 1.0, "{want_p} {want_q}"); + let want_g = upright_angle_after(quad, cross(quad[0], quad[2], quad[1], quad[3])); + assert!(near(angle(&s, g), want_g), "{} vs {want_g}", angle(&s, g)); +} + +#[test] +fn mirrored_and_collapsing_distorts_keep_valid_angles() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + sel(&mut s, &[a]); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + // Corners swapped left for right mirror the art: the box stands at the mirrored angle. + let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + let [tl, tr, br, bl] = OrientedBox::aligned(b).corners(); + s.execute("object.distort", &json!({ "corners": json_points([tr, tl, bl, br]) })).unwrap(); + assert!(near(angle(&s, a), -30.0), "{}", angle(&s, a)); + // A warp that collapses the art to a point leaves the angle as it was. + let n = undo_len(&s); + let c = b.center(); + match s.execute("object.distort", &json!({ "corners": json_points([c; 4]) })) { + Ok(_) => assert!(near(angle(&s, a), -30.0), "{}", angle(&s, a)), + Err(_) => { + assert_eq!(undo_len(&s), n); + assert!(near(angle(&s, a), -30.0)); + } + } +} + +#[test] +fn distort_angles_undo_redo_and_reopen_together() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + let before = anchors(&s, a); + let n = undo_len(&s); + let b = Rect::new(0.0, 0.0, 100.0, 100.0); + s.execute("object.distort", &json!({ "corners": json_points(turned(OrientedBox::aligned(b).corners(), b.center(), 30.0)) })).unwrap(); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Free Distort")); + assert!(near(angle(&s, a), 30.0), "{}", angle(&s, a)); + let warped = anchors(&s, a); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((anchors(&s, a), angle(&s, a)), (before, 0.0)); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(anchors(&s, a), warped); + assert!(near(angle(&s, a), 30.0)); + // A native file keeps the angle. + let data = s.execute("document.save", &json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + s.execute("document.open", &json!({"name": "warped.vectorcraft", "dataBase64": data})).unwrap(); + assert!(near(angle(&s, a), 30.0)); + sel(&mut s, &[a]); + let b = selection_box(&s); + assert!(near(b.angle, 30.0) && near(b.rect.width(), 100.0) && near(b.rect.height(), 100.0), "{b:?}"); + // The next handle drag scales along the turned box: the Right handle 50 pt further out. + 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)); + s.select_tool("selection", ViewInfo::default()).unwrap(); + drag_with(&mut s, &[right, to, to], Mods::default()); + let nb = selection_box(&s); + assert!(near(nb.angle, 30.0) && near(nb.rect.width(), 150.0) && near(nb.rect.height(), 100.0), "{nb:?}"); +} + #[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 7de9793525..f507b765c6 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -571,6 +571,25 @@ fn distort_command_warps_the_linked_mask_of_a_path_in_a_group() { assert_eq!(a.extras().mask.as_ref().unwrap().art.path_data(), a.path_data(), "and so is its mask"); } +/// Corners where the box already stands change nothing: no undo step, the document as it was. A +/// corner moved by a hundredth of a point is a warp. +#[test] +fn unchanged_distort_records_no_history() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let n = undo_len(&s); + let at = |x1: f64| json!([[b.x0, b.y0], [x1, b.y0], [b.x1, b.y1], [b.x0, b.y1]]); + assert_eq!(s.execute("object.distort", &json!({"corners": at(b.x1)})).unwrap()["ids"], json!([a.0])); + assert_eq!(undo_len(&s), n); + assert!(s.doc().unwrap().doc == doc, "the document is unchanged"); + s.execute("object.distort", &json!({"corners": at(b.x1 + 0.01)})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + assert!(s.doc().unwrap().doc != doc); +} + #[test] fn distort_perspective_midpoint_is_projective() { // Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real @@ -1239,7 +1258,7 @@ fn distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle() { } 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); + assert_eq!(undo_len(&s), history, "corners where the box stands change nothing"); // 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)); diff --git a/docs/mcp.md b/docs/mcp.md index cb5c40501c..62a6960b8b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5290,6 +5290,13 @@ box is; without `from`, that box is the selection's bounds measured in the turne 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. +After the warp, each warped object's bounding box stands at the angle the warp turns the object's own x axis to at +the object's center, as measured before the warp. This covers the objects inside a warped group, compound path or clip +group, each at its own center, and the art of a linked opacity mask; an unlinked mask's art keeps its angle. A warp +that mirrors the object keeps the angle nearer the old one, and an object whose x axis the warp collapses keeps its +angle. `object.resetBoundingBox` squares the boxes to the page again. Corners that equal the corners of the box being +warped (`from`, or the selection's bounds) change nothing and add no undo step. This angle rule is VectorCraft's own. + ```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]]}}} ``` @@ -7965,7 +7972,8 @@ CMYK one. Settings saved before these options load with their defaults. Every object keeps the angle of its own axes (counter-clockwise degrees; `document.inspect` reports it as a node's `rotation`, and the selection's shared angle as `selectionRotation`, 0 when the selected objects differ). Transforms turn it with the object while its path geometry is still baked, so after a rotation the bounding box and its handles -stay square to it, as in the reference app. `object.rotate {angle, absolute: true}` turns the selection to an angle +stay square to it, as in the reference app. `object.distort` turns it as the warp turns the object at its center +(see Free Distort on a turned bounding box). `object.rotate {angle, absolute: true}` turns the selection to an angle (the Transform and Properties panels' Rotate field), and `object.setBounds` measures width, height and the reference point along the turned box (x, y stay page coordinates). Objects turned together share the box. A new group starts square to the page. `object.resetBoundingBox` squares the box again without moving the art. It also squares every diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e309f72409..607bc1d13c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -888,6 +888,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`). + - Perspective Distort and Free Distort (`object.distort`) turn each warped object's bounding box as the warp turns the object at its own center, the objects inside groups, compound paths and clip groups and the art of linked opacity masks included, so the next Selection or Free Transform drag uses the tilted box; Object › Transform › Reset Bounding Box squares it again, and corners left where they are add no undo step (VectorCraft's own rule; `bboxrotate::distort_turns_an_upright_box_with_the_warp_at_its_center`, `distort_turns_each_member_and_linked_mask_by_its_own_warp`, `mirrored_and_collapsing_distorts_keep_valid_angles`, `distort_angles_undo_redo_and_reopen_together`, `xform::unchanged_distort_records_no_history`). - The Free Transform widget's Constrain works in Free Distort as well as in Free Transform ([the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/free-transform-tool.html)). With Constrain on, or Shift held, a corner dragged in Free Distort moves only along the line through the opposite corner, in the box's own proportions, and the other three corners stay in place; on a turned box the line runs through the box's own corners. The line is VectorCraft's own rule. Cmd/Ctrl+Alt+Shift on a corner still drags in Perspective Distort, where Constrain stays unavailable (tools `xform::free::tests::free_distort_corner_keeps_to_its_diagonal_with_shift_or_constrain`, `shift_pressed_during_a_free_distort_drag_constrains_it`, `a_turned_box_is_constrained_along_its_own_diagonal`, `along_diagonal_of_a_degenerate_box`; engine `xform::free_transform_constrained_distort_keeps_the_corner_on_its_diagonal`; ui-egui `free_transform::tests::the_widget_sets_the_mode_and_a_corner_drag_then_distorts`). - 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`).