From cd88b15912962ae93311dcbd1bc21bce13bc43df Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:56:24 -0500 Subject: [PATCH] Shape tools: review follow-ups (Shift, handle boundary, cursor order, feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Selection tool takes a Shift-press on a selected object's centre point too: a Shift-drag moves the selection held to 45° steps, and a Shift-click there takes the object out of the selection, as a Shift-press on the object does. - One rotate zone for the Selection tool and the shape tools (`select::rotate_zone`): a press on a bounding-box handle is not a turn, so the shape tools' zone matches the Selection tool's classification exactly. - The shape tools' hover cursor follows the press order (corner and pie widgets, then the centre point, then the rotate zone), and a move of the selection shows the Selection tool's feedback: SelectionCopy with Alt, ArrowHollow while it snaps. - Engine-level tests for the whole gesture (`crates/engine/src/tests/shapetools.rs`): repeated previews and one undo step, Alt copy, Escape then release, a turn about the centre, the corner widget still rounding, and an unfilled shape moved by the Selection tool. - docs: evidence in target-app-parity.md's Shapes bullet and the M2 milestone, and the Move cursor's entry names the copy and snap cursors. --- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/shapetools.rs | 133 ++++++++++++++++++++++++++ crates/tools/src/cursors.toml | 2 +- crates/tools/src/select.rs | 25 +++-- crates/tools/src/select/tests.rs | 30 ++++++ crates/tools/src/shape.rs | 54 ++++++----- crates/tools/src/shape/tests.rs | 12 ++- docs/roadmap.md | 1 + docs/target-app-parity.md | 2 +- 9 files changed, 225 insertions(+), 35 deletions(-) create mode 100644 crates/engine/src/tests/shapetools.rs diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 812a6282f..862bc4b77 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -147,6 +147,7 @@ mod scalestrokes; mod scripts; mod select_ids_first; mod shaper; +mod shapetools; mod shortcutset; mod simplify_part; mod slices; diff --git a/crates/engine/src/tests/shapetools.rs b/crates/engine/src/tests/shapetools.rs new file mode 100644 index 000000000..39198966f --- /dev/null +++ b/crates/engine/src/tests/shapetools.rs @@ -0,0 +1,133 @@ +//! Shape tools: the selection they keep on show moves by its centre point (Attributes › Show +//! Center) and turns from the rotate zone just outside a corner of its box, as the Selection +//! tool's do, through the whole engine (#5477). + +use serde_json::json; +use vectorcraft_doc::{LiveShape, NodeKind}; +use vectorcraft_geom::{Point, Rect, Vec2}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use crate::*; + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn view() -> ViewInfo { + ViewInfo { smart_guides: false, ..ViewInfo::default() } +} + +fn gesture(s: &mut Session, mods: Mods, events: &[(PointerKind, f64, f64)]) { + for (k, x, y) in events { + s.pointer(&PointerEvent::new(*k, *x, *y).with_mods(mods), view()).unwrap(); + } +} + +const NONE: Mods = Mods { shift: false, alt: false, cmd: false, ctrl: false, space: false }; + +/// A 100 × 80 pt rectangle drawn from (100, 100) to (200, 180) with the Rectangle tool, still +/// selected: its centre point is at (150, 140). +fn drawn_rect(s: &mut Session) -> NodeId { + s.select_tool("rectangle", view()).unwrap(); + gesture(s, NONE, &[(PointerKind::Down, 100.0, 100.0), (PointerKind::Drag, 200.0, 180.0), (PointerKind::Up, 200.0, 180.0)]); + *s.doc().unwrap().selection.objects.first().expect("the drawn rectangle is selected") +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.bounds_of(&[id], false).expect("bounds") +} + +fn angle(s: &Session, id: NodeId) -> f64 { + s.doc().unwrap().doc.oriented_bounds(&[id], false).map_or(0.0, |b| b.angle) +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// With the Rectangle tool still active, a drag from the shape's centre point moves it, in one +/// undo step; Alt-dragging it copies it; Escape turns a drag back. +#[test] +fn a_shape_tool_moves_the_shape_from_its_centre_point() { + let mut s = new_doc(); + let id = drawn_rect(&mut s); + let before = bounds(&s, id); + let steps = undo_steps(&s); + gesture( + &mut s, + NONE, + &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 170.0, 160.0), (PointerKind::Drag, 180.0, 170.0), (PointerKind::Up, 180.0, 170.0)], + ); + assert_eq!(bounds(&s, id), before + Vec2::new(30.0, 30.0), "moved by the drag"); + assert_eq!(undo_steps(&s), steps + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(bounds(&s, id), before); + // Alt-dragging from the centre leaves a copy where the original was. + gesture( + &mut s, + Mods { alt: true, ..NONE }, + &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 200.0, 140.0), (PointerKind::Up, 200.0, 140.0)], + ); + let objects = s.doc().unwrap().doc.layers[0].children().map(|c| c.len()).unwrap_or_default(); + assert_eq!(objects, 2, "a copy"); + // Escape turns the drag back, and the release leaves the art alone. + let steps = undo_steps(&s); + let moved = s.doc().unwrap().selection.objects.clone(); + let copy = *moved.first().expect("the copy is selected"); + let at = bounds(&s, copy); + gesture(&mut s, NONE, &[(PointerKind::Down, 200.0, 140.0), (PointerKind::Drag, 250.0, 190.0)]); + s.tool_key(ToolKey::Escape, NONE, view()).unwrap(); + gesture(&mut s, NONE, &[(PointerKind::Up, 250.0, 190.0)]); + assert_eq!(undo_steps(&s), steps, "no step"); + assert_eq!(s.doc().unwrap().selection.objects, moved); + assert_eq!(bounds(&s, copy), at, "back where it was"); +} + +/// A press in the rotate zone just outside a corner of the shape's box turns it about that box's +/// centre, as the Selection tool's does; a press on the corner itself still rounds it. +#[test] +fn a_shape_tool_turns_the_shape_from_just_outside_a_corner() { + let mut s = new_doc(); + let id = drawn_rect(&mut s); + // 8.49 pt outside the top-left corner (100, 100), past the handle's 5 pt square. + gesture( + &mut s, + NONE, + &[(PointerKind::Down, 94.0, 94.0), (PointerKind::Drag, 150.0, 70.0), (PointerKind::Drag, 150.0, 70.0), (PointerKind::Up, 150.0, 70.0)], + ); + let turned = angle(&s, id); + assert!(turned.abs() > 1.0, "turned: {turned}"); + let centre = bounds(&s, id).center(); + assert!((centre - Point::new(150.0, 140.0)).hypot() < 1e-6, "about its centre: {centre:?}"); + // A press on a Live Corners widget rounds it, as before (#5477): the widget keeps the corner, + // and sits 10 pt inside it. + let mut s = new_doc(); + let id = drawn_rect(&mut s); + gesture(&mut s, NONE, &[(PointerKind::Down, 110.0, 110.0), (PointerKind::Drag, 125.0, 125.0), (PointerKind::Up, 125.0, 125.0)]); + let n = s.doc().unwrap().doc.node(id).expect("the rectangle"); + let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("a live shape: {:?}", n.kind) }; + let LiveShape::Rectangle { radii, .. } = &**live else { panic!("a rectangle") }; + assert!(radii[0] > 0.0, "the corner rounded: {radii:?}"); + assert_eq!(angle(&s, id), 0.0, "rounding does not turn it"); +} + +/// The Selection tool takes the centre point of an unfilled shape too: a drag there moves it +/// instead of starting a marquee. +#[test] +fn the_selection_tool_moves_an_unfilled_shape_from_its_centre_point() { + let mut s = new_doc(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("selection", view()).unwrap(); + let before = bounds(&s, id); + gesture(&mut s, NONE, &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 170.0, 160.0), (PointerKind::Up, 170.0, 160.0)]); + assert_eq!(bounds(&s, id), before + Vec2::new(20.0, 20.0)); + // Away from it, a drag is still a marquee. + let steps = undo_steps(&s); + gesture(&mut s, NONE, &[(PointerKind::Down, 300.0, 300.0), (PointerKind::Drag, 350.0, 350.0), (PointerKind::Up, 350.0, 350.0)]); + assert_eq!(undo_steps(&s), steps, "a marquee changes no art"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the marquee took nothing"); +} diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 7071f24e8..aa1f28dc0 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -94,7 +94,7 @@ id = "Move" group = "Selection" shown_when = [ "The Selection tool over a selected object, or over any object with Alt held (once an Alt-drag starts, SelectionCopy shows).", - "The shape tools over a selected object's centre point (Attributes › Show Center), and while dragging it.", + "The shape tools over a selected object's centre point (Attributes › Show Center), and while dragging it (SelectionCopy with Alt, ArrowHollow while it snaps).", "Reshape over a path or while dragging.", "Inside the Free Transform and Crop Image boxes.", "Puppet Warp over a pin.", diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index fd6cc0f5c..5a75c2a84 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -527,13 +527,25 @@ enum BoxHit { Rotate, } +/// How far (screen points) outside a corner of the bounding box a press turns the selection: the +/// Selection tool's rotate zone, and the shape tools' (#5477). +pub(crate) const ROTATE_ZONE: f64 = 18.0; + +/// Where a press at `p` just outside a corner of box `b` turns the selection about its centre, as +/// the Selection tool's rotate zone does: the box's centre. None on a handle, on the corner itself +/// or away from every corner. +pub(crate) fn rotate_zone(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option { + let (tol, lp) = (cx.tol(5.0), b.to_local(p)); + (hit_handle(b.rect, lp, tol).is_none() && in_rotate_zone(b.rect, lp, tol, cx.tol(ROTATE_ZONE)).is_some()).then(|| b.center()) +} + /// The bounding-box handle or rotate zone under `p`. fn box_hit(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option { let (tol, lp) = (cx.tol(5.0), b.to_local(p)); if let Some(h) = hit_handle(b.rect, lp, tol) { return Some(BoxHit::Handle(h)); } - in_rotate_zone(b.rect, lp, tol, cx.tol(18.0)).map(|_| BoxHit::Rotate) + in_rotate_zone(b.rect, lp, tol, cx.tol(ROTATE_ZONE)).map(|_| BoxHit::Rotate) } /// Whether the current selection's bounding-box handle is under `p`, using the same geometry as @@ -838,13 +850,14 @@ impl Tool for SelectionTool { // 2b. A selected object's centre point (Attributes › Show Center), drawn while it // is selected: a drag from it moves the selection, also where no art is painted // (an unfilled shape), which a press there would otherwise take for a marquee. A - // click there is a click on that object, so it makes it the key object too. - if !m.shift - && !m.cmd + // click there is a click on that object: it makes it the key object of several, + // and a Shift-click takes it out of the selection on release. + if !m.cmd && let Some((id, center)) = hit_center(cx, p, cx.point_tol()) { - let key = ((cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty()) && !m.alt).then_some(id); - self.state = State::Moving { start: p, press: p, hold: center, began: false, deselect: None, key, exclude: None }; + let several = cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty(); + let (deselect, key) = if m.shift { (Some(id), None) } else { (None, (several && !m.alt).then_some(id)) }; + self.state = State::Moving { start: p, press: p, hold: center, began: false, deselect, key, exclude: None }; return vec![]; } // 3. Objects: Cmd/Ctrl-click selects the one under the selected one (cycling). diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 027bfb203..7268d0678 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2169,3 +2169,33 @@ fn a_press_on_the_centre_point_moves_an_unfilled_shape() { assert!(a.is_empty(), "{a:?}"); assert!(matches!(t.overlays(&c).first(), Some(Overlay::Marquee(_))), "a marquee"); } + +/// A Shift-press on a selected object's centre point moves the selection too, held to 45° steps, +/// and a Shift-click there (no drag) takes the object out of the selection, as a Shift-press on +/// the object itself does. +#[test] +fn a_shift_press_on_the_centre_point_moves_or_deselects() { + let (d, id) = doc_with_live_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Mods::default() }; + let centre = Point::new(150.0, 150.0); + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y).with_mods(shift)).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 160.0).with_mods(shift)); + assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{a:?}"); + let Some(Action::Preview(cmd, v)) = a.last().filter(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")) else { panic!("{a:?}") }; + assert_eq!(cmd, "object.transform"); + let [_, _, _, _, dx, dy] = [0, 1, 2, 3, 4, 5].map(|i| v["matrix"][i].as_f64().unwrap_or_default()); + assert!(dx != 0.0 && dx == dy, "held to 45°: {dx} {dy}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 160.0).with_mods(shift)), vec![Action::Commit]); + // A Shift-click there takes the object out of the selection on release. + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y).with_mods(shift)).is_empty()); + assert_eq!( + t.pointer(&c, &ev(PointerKind::Up, centre.x, centre.y).with_mods(shift)), + vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))] + ); +} diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index b1d2a8558..d513b1f5e 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -18,11 +18,11 @@ use serde_json::{Value, json}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; -use crate::bbox::{in_rotate_zone, move_delta, rotate_for_drag}; +use crate::bbox::{move_delta, rotate_for_drag}; use crate::corners::{self, CornerDrag}; use crate::guides::{DrawSnap, Leave, square}; use crate::pie::{self, PieDrag}; -use crate::select::{MoveSnap, hit_center, matrix_json, shown_box}; +use crate::select::{MoveSnap, hit_center, matrix_json, rotate_zone, shown_box}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; /// The largest corner radius the Rounded Rectangle tool draws, points: the range of General › @@ -30,10 +30,6 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool /// value stop there too. pub const MAX_CORNER_RADIUS: f64 = 1296.0; -/// How far (screen points) outside a corner of the selection's box a press with a shape tool turns -/// the selection, as the Selection tool's rotate zone does. -const ROTATE_ZONE: f64 = 18.0; - /// Does tool `id` keep the selection's bounding box and Live Shape widgets on show and let its /// corner and pie widgets be dragged (#5477)? The shape tools that draw a box: not Line Segment. pub fn shows_shape_widgets(id: &str) -> bool { @@ -83,6 +79,9 @@ pub struct ShapeTool { guides: Vec, /// The measurement label of a move or a turn: where it goes and what it reads. measure: Option<(Point, String)>, + /// The move of the selection snapped to a point, a guide or the grid at its last step: the + /// arrow turns hollow, as the Selection tool's does. + snapped: bool, } /// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each @@ -122,6 +121,7 @@ impl ShapeTool { selection: None, guides: vec![], measure: None, + snapped: false, } } @@ -145,11 +145,11 @@ impl ShapeTool { shows_shape_widgets(self.id).then(|| shown_box(cx)).flatten() } - /// Is `p` just outside a corner of the selection's box, where a press turns the selection about - /// its centre ([`ROTATE_ZONE`])? On the corner itself the Live Corners widget rounds it (#5477). + /// Is `p` in the zone just outside a corner of the selection's box, where a press turns the + /// selection about its centre ([`crate::select::rotate_zone`])? On the corner itself the Live + /// Corners widget rounds it (#5477). fn over_rotate(&self, cx: &ToolContext, p: Point) -> Option { - let bx = self.box_of(cx)?; - in_rotate_zone(bx.rect, bx.to_local(p), cx.tol(5.0), cx.tol(ROTATE_ZONE)).map(|_| bx.center()) + rotate_zone(cx, &self.box_of(cx)?, p) } /// Is `p` over the centre point of a selected object (Attributes › Show Center), where a press @@ -194,11 +194,11 @@ impl ShapeTool { out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() })); } let shift = ev.mods.shift; - let (delta, guides) = if shift { - snap.snap_along(cx, *hold, move_delta(*start, ev.pos, true, cx.constrain_angle), ev.mods.alt) - } else { - snap.snap(cx, *hold, ev.pos - *start, ev.mods.alt) - }; + let unsnapped = move_delta(*start, ev.pos, shift, cx.constrain_angle); + let (delta, guides) = + if shift { snap.snap_along(cx, *hold, unsnapped, ev.mods.alt) } else { snap.snap(cx, *hold, unsnapped, ev.mods.alt) }; + // Shift has already constrained the move: any change is a snap. + self.snapped = (delta - unsnapped).hypot() > 1e-9; self.guides = guides; self.measure = cx.measurement_labels.then(|| (ev.pos, cx.offset_label(delta.x, delta.y))); out.push(Action::Preview("object.transform".into(), json!({"matrix": matrix_json(Affine::translate(delta)), "copy": ev.mods.alt}))); @@ -214,6 +214,7 @@ impl ShapeTool { self.selection = None; self.guides.clear(); self.measure = None; + self.snapped = false; if began { vec![Action::Commit] } else { vec![] } } (PointerKind::Up, SelectionDrag::Rotate { .. }) => { @@ -231,6 +232,7 @@ impl ShapeTool { self.selection = None; self.guides.clear(); self.measure = None; + self.snapped = false; vec![Action::Cancel] } @@ -586,7 +588,7 @@ impl Tool for ShapeTool { } o } - fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { // Over a widget a press would drag (or dragging one), the Selection tool's pointer for it. let idle = self.start.is_none(); match &self.widget { @@ -594,26 +596,30 @@ impl Tool for ShapeTool { Some(WidgetDrag::Pie(_)) => return Cursor::ArrowHollow, None => {} } + // Alt-dragging the selection leaves a copy behind; otherwise the arrow, hollow while the + // move snaps, as the Selection tool's. match &self.selection { Some(SelectionDrag::Rotate { center, .. }) => return Cursor::rotate_about_in_view(*center, p, cx.view_angle()), + Some(SelectionDrag::Move { began: true, .. }) if m.alt => return Cursor::SelectionCopy, + Some(SelectionDrag::Move { began: true, .. }) => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow }, Some(_) => return Cursor::Arrow, None => {} } - // The selection's centre point moves it and the zone just outside a corner turns it; - // everywhere else a press draws a new shape. + // A press takes the corner and pie widgets first, then the centre point, then the zone + // just outside a corner; everywhere else it draws a new shape. if idle { - if self.over_center(cx, p) { - return Cursor::Move; - } - if let Some(center) = self.over_rotate(cx, p) { - return Cursor::rotate_about_in_view(center, p, cx.view_angle()); - } if shows_shape_widgets(self.id) && corners::over_widget(cx, p, false) { return Cursor::CornerRadius; } if self.over_widget(cx, p) { return Cursor::ArrowHollow; } + if self.over_center(cx, p) { + return Cursor::Move; + } + if let Some(center) = self.over_rotate(cx, p) { + return Cursor::rotate_about_in_view(center, p, cx.view_angle()); + } } Cursor::Crosshair } diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index e53cea057..9f45a6630 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -469,9 +469,15 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn t.pointer(&hidden, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)); let a = t.pointer(&hidden, &PointerEvent::new(PointerKind::Drag, 170.0, 160.0)); assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{a:?}"); - // Just outside the bottom-right corner (7.07 pt away, past the corner widget's 5): a press - // there turns the selection about its centre. - let outside = Point::new(205.0, 205.0); + // On the corner's bounding-box handle, and on the corner widget itself, no turn: those keep + // their own gestures (the widget rounds the corner, the handle scales with the Selection tool). + for on_handle in [Point::new(205.0, 205.0), Point::new(200.0, 200.0)] { + let t = ShapeTool::new("rectangle"); + assert_ne!(t.cursor(&c, on_handle, Mods::default()), Cursor::rotate_about(centre, on_handle), "{on_handle:?}"); + } + // Just outside the bottom-right corner, past the handle's 5 pt square and within the 18 pt + // zone (8.49 pt away): a press there turns the selection about its centre. + let outside = Point::new(206.0, 206.0); let mut t = ShapeTool::new("rectangle"); assert_eq!(t.cursor(&c, outside, Mods::default()), Cursor::rotate_about(centre, outside)); // A turn begins with the press, as the Selection tool's does (its drags preview into it). diff --git a/docs/roadmap.md b/docs/roadmap.md index dd5504b0d..e29338b76 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -118,6 +118,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | +| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1a9c2c2c7..3da7b29cf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -796,7 +796,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open. With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes. - - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). + - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation (unturned, or turned by a nonzero General › Constrain Angle), and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).