From 82bf9624bfebcb147672a8e9c6354c0110319d36 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:44:24 -0800 Subject: [PATCH 01/30] Smooth: the cursor shows Not Allowed when no path is selected to smooth The Smooth tool showed its own cursor with nothing selected, though a drag then changes nothing. It now shows Not Allowed until a path it can act on is selected (an editable path, not a guide, with three or more anchors on one of its subpaths); a selected path anywhere on the canvas gives the Smooth cursor wherever the pointer is. - The Pencil with Alt key toggles to Smooth Tool on and the Blob Brush show Not Allowed the same way when Alt gives them the Smooth tool. The Paintbrush keeps its closing cursor, since its release with Alt closes the stroke. - A Smooth release, from the Smooth tool or from the Pencil, Paintbrush or Blob Brush with Alt, sends path.smoothRegion only while such a path is selected. With only type or other objects selected it sends no command. - Use Precise Cursors leaves Not Allowed as it is. - The cursor catalog and docs/target-app-parity.md describe the rule. --- crates/engine/src/tests/prefs.rs | 22 +++- crates/tools/src/cursors.toml | 9 +- crates/tools/src/draw2/gesture.rs | 22 ++-- crates/tools/src/draw2/gesture/tests.rs | 131 ++++++++++++++++++++++-- crates/tools/src/lib.rs | 12 ++- docs/target-app-parity.md | 3 +- 6 files changed, 179 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 37127f686a..121ada4a2a 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -665,7 +665,6 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() { ("blobBrush", Cursor::BlobBrush), ("shaper", Cursor::Shaper), ("pencil", Cursor::PencilNew), - ("smooth", Cursor::Smooth), ("pathEraser", Cursor::PathEraser), ("join", Cursor::Join), ("eraser", Cursor::Eraser), @@ -710,6 +709,27 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() { } } +/// With no selected path, the Smooth tool shows Not Allowed, and Use Precise Cursors leaves it so; with a +/// selected path, it shows its own pointer, or a crosshair with the preference on. +#[test] +fn use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer() { + use vectorcraft_tools::{Cursor, Mods}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let at = vectorcraft_geom::Point::new(300.0, 300.0); + s.select_tool("smooth", ViewInfo::default()).unwrap(); + for precise in [false, true] { + s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap(); + assert_eq!(s.cursor(at, Mods::default(), ViewInfo::default()), Cursor::NotAllowed, "precise {precise}"); + } + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (precise, c) in [(false, Cursor::Smooth), (true, Cursor::Crosshair)] { + s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap(); + assert_eq!(s.cursor(at, Mods::default(), ViewInfo::default()), c, "precise {precise}"); + } +} + /// With Alt held while drawing, the Pencil and the Paintbrush show their closing cursors, and Use Precise Cursors /// turns those into a crosshair too. #[test] diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 6f5b32e56a..674ecebf60 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -453,6 +453,7 @@ group = "Navigation" shown_when = [ "The Rotate, Reflect, Scale and Shear tools with nothing selected.", "Puppet Warp off the mesh, where no pin can go.", + "The Smooth tool, and the Pencil or Blob Brush where Alt gives the Smooth tool, when no path is selected that the Smooth tool can act on: a drag changes nothing.", ] hotspot = "The centre." designer = false @@ -690,10 +691,10 @@ look = "Only a small dot at the centre, as the Blob Brush's: the tip outline dra id = "Smooth" group = "Drawing and editing tools" shown_when = [ - "The Smooth tool: a drag along a selected path smooths it.", - "The Pencil tool with Alt key toggles to Smooth Tool on: with Alt held before a stroke begins to draw, and through a drag that smooths.", - "The Paintbrush tool with Alt held before a press while a path is selected that the Smooth tool can act on (an editable path, not a guide, with three or more anchors on one of its subpaths), and through a drag that smooths.", - "The Blob Brush tool with Alt held before a press, or before the drag has moved from the press point, and through a drag that smooths.", + "The Smooth tool while a path is selected that it can act on (an editable path, not a guide, with three or more anchors on one of its subpaths), wherever the pointer is: a drag along the path smooths it. With no such path selected, Not Allowed shows.", + "The Pencil tool with Alt key toggles to Smooth Tool on and such a path selected: with Alt held before a stroke begins to draw, and through a drag that smooths.", + "The Paintbrush tool with Alt held before a press while such a path is selected, and through a drag that smooths.", + "The Blob Brush tool with such a path selected: with Alt held before a press, or before the drag has moved from the press point, and through a drag that smooths.", ] hotspot = "The tip." diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 2de44f97ba..a96cffd307 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -763,7 +763,8 @@ fn has_selected_smooth_path(cx: &ToolContext) -> bool { cx.selection.objects.iter().filter_map(|id| cx.doc.node(*id)).any(|n| { let mut found = false; n.walk(&mut |child| { - if matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(|sp| sp.anchors.len() >= 3)) + if !found + && matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.subpaths.iter().any(|sp| sp.anchors.len() >= 3) && path.control_bounds().is_some()) && editable(child.id) { found = true; @@ -773,6 +774,13 @@ fn has_selected_smooth_path(cx: &ToolContext) -> bool { }) } +/// The Smooth tool's cursor: Not Allowed when no selected path can be smoothed, since a drag then +/// changes nothing. A selected path anywhere on the canvas is enough, so the cursor does not depend +/// on where the pointer is. +fn smooth_cursor(cx: &ToolContext) -> Cursor { + if has_selected_smooth_path(cx) { Cursor::Smooth } else { Cursor::NotAllowed } +} + impl GestureTool { pub fn new(id: &str) -> Self { let id: &'static str = match id { @@ -1147,7 +1155,7 @@ impl GestureTool { let tol = cx.tol(self.edit_within); let radius = cx.tol(SMOOTH_RADIUS); if self.smoothing { - if has_sel && pts.len() >= 2 { + if pts.len() >= 2 && has_selected_smooth_path(cx) { return vec![Action::Exec( "path.smoothRegion".into(), json!({"points": points_json(pts), "radius": radius, "fidelity": self.smooth_fidelity}), @@ -1199,7 +1207,7 @@ impl GestureTool { }), )], "knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))], - "smooth" if has_sel && pts.len() >= 2 => { + "smooth" if pts.len() >= 2 && has_selected_smooth_path(cx) => { vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": radius, "fidelity": self.fidelity}))] } "pathEraser" if has_sel => vec![Action::Exec("path.eraseSegments".into(), json!({"points": points_json(pts), "width": cx.tol(8.0)}))], @@ -1504,13 +1512,15 @@ impl Tool for GestureTool { } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { // An active smoothing drag, or Alt before a press that would switch the Pencil, Paintbrush - // or Blob Brush to Smooth. A Paintbrush stroke already drawing keeps drawing with Alt. + // or Blob Brush to Smooth. A Paintbrush stroke already drawing keeps drawing with Alt. With + // no selected path to smooth, the Pencil and the Blob Brush show Not Allowed, as the Smooth + // tool does. if self.smoothing || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) || (!self.active && self.id == "paintbrush" && m.alt && has_selected_smooth_path(cx)) || self.blob_alt_smooths(m) { - return Cursor::Smooth; + return smooth_cursor(cx); } // Before a press that would draw a straight line: over an end of a selected open path, // the line continues that path from the end (`PointerKind::Down`). @@ -1533,7 +1543,7 @@ impl Tool for GestureTool { "blobBrush" => Cursor::BlobBrush, "eraser" => Cursor::Eraser, "knife" => Cursor::Knife, - "smooth" => Cursor::Smooth, + "smooth" => smooth_cursor(cx), "pathEraser" => Cursor::PathEraser, "join" => Cursor::Join, // Before a press that draws a new path the Pencil shows its star; over a selected path diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 624adc2e98..98e154f870 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -369,7 +369,7 @@ fn the_freehand_tools_want_every_sample() { #[test] fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() { - let (d, _) = doc_with_rect(); + let (d, rect) = doc_with_rect(); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -396,18 +396,26 @@ fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() { } // With Alt key toggles to Smooth Tool on, Alt lends the Pencil the Smooth tool instead, until // a stroke has begun to draw: Alt then neither smooths nor closes, and the cursor stays the Pencil's. + // With nothing selected to smooth, the Smooth tool it lends shows Not Allowed. let mut t = GestureTool::new("pencil"); t.set_option("altSmooth", &json!(true)); - assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth); + assert_eq!(t.cursor(&cx, at, alt), Cursor::NotAllowed); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); assert_eq!(t.cursor(&cx, at, alt), Cursor::Pencil, "a stroke that has begun"); let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt)); assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false), "{acts:?}"); - // A smoothing drag keeps the Smooth cursor when Alt is let go. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); - assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth, "a smoothing drag"); + // A smoothing drag keeps the lent cursor when Alt is let go: Smooth with a selected path, Not + // Allowed without one. + let mut selected = Selection::default(); + selected.set([rect]); + for (sel, c) in [(&s, Cursor::NotAllowed), (&selected, Cursor::Smooth)] { + let cx = crate::testutil::cx(&d, sel, &p); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, at, Mods::default()), c, "a smoothing drag"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt)); + } } #[test] @@ -2223,6 +2231,117 @@ fn smooth_needs_selection_and_brackets_resize() { assert_eq!(b.options()["size"], 11.0); } +/// The Smooth tool shows Not Allowed while no selected path can be smoothed: nothing selected, only +/// type, a path of two anchors or a locked path. A selected path anywhere, alone or in a selected +/// group, gives the Smooth cursor wherever the pointer is, and the cursor follows the selection while +/// the tool stays chosen. +#[test] +fn smooth_shows_not_allowed_without_a_selected_path_to_smooth() { + use std::sync::Arc; + let (mut d, rect) = doc_with_rect(); + let layer = d.layers[0].id; + let line_id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(layer), 1, vectorcraft_doc::Node::path(line_id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 350.0, 350.0, 400.0)); + let inner = vectorcraft_doc::Node::path(d.alloc_id(), square, vectorcraft_doc::Appearance::default_art()); + let group = vectorcraft_doc::Node::group(d.alloc_id(), vec![Arc::new(inner)]); + let group_id = group.id; + d.insert(Some(layer), 2, group).unwrap(); + let (text_doc, text) = doc_with_area_type(); + let mut locked = d.clone(); + locked.node_mut(rect).unwrap().locked = true; + let p = paint(); + let sel = |ids: &[NodeId]| { + let mut s = Selection::default(); + s.set(ids.iter().copied()); + s + }; + let t = GestureTool::new("smooth"); + let far = Point::new(480.0, 20.0); + let cases: [(&vectorcraft_doc::Document, Selection, Cursor, &str); 7] = [ + (&d, Selection::default(), Cursor::NotAllowed, "nothing selected"), + (&text_doc, sel(&[text]), Cursor::NotAllowed, "type"), + (&d, sel(&[line_id]), Cursor::NotAllowed, "a two-anchor path"), + (&locked, sel(&[rect]), Cursor::NotAllowed, "a locked path"), + (&d, sel(&[rect]), Cursor::Smooth, "a path away from the pointer"), + (&d, sel(&[line_id, rect]), Cursor::Smooth, "a path beside one it cannot smooth"), + (&d, sel(&[group_id]), Cursor::Smooth, "a group holding a path"), + ]; + for (doc, s, c, what) in &cases { + assert_eq!(t.cursor(&cx(doc, s, &p), far, Mods::default()), *c, "{what}"); + } + // The cursor changes as the selection does, with the same tool. + let (none, one) = (Selection::default(), sel(&[rect])); + for (s, c) in [(&none, Cursor::NotAllowed), (&one, Cursor::Smooth), (&none, Cursor::NotAllowed)] { + assert_eq!(t.cursor(&cx(&d, s, &p), Point::new(150.0, 150.0), Mods::default()), c); + } + // A drag with Not Allowed showing sends nothing. + let mut t = GestureTool::new("smooth"); + let acts = drag(&mut t, &cx(&d, &none, &p), &[(50.0, 150.0), (150.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!(acts.iter().all(|a| a.is_empty()), "{acts:?}"); +} + +/// The Blob Brush's Alt shows Not Allowed with no selected path to smooth, and the Smooth cursor with +/// one; the Paintbrush's Alt without one keeps the closing cursor, since its release closes the stroke. +#[test] +fn borrowed_smooth_shows_not_allowed_without_a_selected_path_to_smooth() { + let (d, rect) = doc_with_rect(); + let none = Selection::default(); + let mut one = Selection::default(); + one.set([rect]); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let at = Point::new(400.0, 400.0); + let blob = GestureTool::new("blobBrush"); + assert_eq!(blob.cursor(&cx(&d, &none, &p), at, alt), Cursor::NotAllowed); + assert_eq!(blob.cursor(&cx(&d, &one, &p), at, alt), Cursor::Smooth); + assert_eq!(blob.cursor(&cx(&d, &none, &p), at, Mods::default()), Cursor::BlobBrush); + let mut pencil = GestureTool::new("pencil"); + pencil.set_option("altSmooth", &json!(true)); + assert_eq!(pencil.cursor(&cx(&d, &none, &p), at, alt), Cursor::NotAllowed); + assert_eq!(pencil.cursor(&cx(&d, &one, &p), at, alt), Cursor::Smooth); + let brush = GestureTool::new("paintbrush"); + assert_eq!(brush.cursor(&cx(&d, &none, &p), at, alt), Cursor::PaintbrushClose); + assert_eq!(brush.cursor(&cx(&d, &one, &p), at, alt), Cursor::Smooth); +} + +/// A Smooth release sends no command while the selection holds no path it can smooth, from the +/// Smooth tool and from the Blob Brush and Pencil with Alt; with a path selected it smooths. +#[test] +fn smoothing_release_sends_nothing_without_a_selected_path_to_smooth() { + let (text_doc, text) = doc_with_area_type(); + let (d, rect) = doc_with_rect(); + let mut type_only = Selection::default(); + type_only.set([text]); + let mut one = Selection::default(); + one.set([rect]); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let stroke = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)]; + let smooths = |acts: &[Vec]| acts.iter().flatten().any(|a| matches!(a, Action::Exec(c, _) if c == "path.smoothRegion")); + for (id, mods) in [("smooth", Mods::default()), ("blobBrush", alt), ("pencil", alt)] { + let tool = || { + let mut t = GestureTool::new(id); + if id == "pencil" { + t.set_option("altSmooth", &json!(true)); + } + t + }; + let mut c = cx(&text_doc, &type_only, &p); + c.smart_guides = false; + let acts = drag_held(&mut tool(), &c, &stroke, mods); + assert!(!smooths(&acts), "{id} with only type selected: {acts:?}"); + let mut c = cx(&d, &one, &p); + c.smart_guides = false; + let acts = drag_held(&mut tool(), &c, &stroke, mods); + assert!(smooths(&acts), "{id} with a path selected: {acts:?}"); + } +} + #[test] fn pencil_alt_toggles_to_smooth_when_enabled_and_closes_when_disabled() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 17e6f038e9..1447847213 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -619,8 +619,16 @@ mod cursor_tests { /// Use Precise Cursors turns it into the crosshair. #[test] fn drawing_tools_show_their_own_cursor() { - let (d, _) = doc_with_rect(); + let (d, rect) = doc_with_rect(); let away = Point::new(400.0, 400.0); + // The Smooth tool shows its own cursor only with a selected path (Not Allowed without one). + let mut selected = Selection::default(); + selected.set([rect]); + let paint = paint(); + let cursor_of = |id: &str| match id { + "smooth" => create(id).cursor(&cx(&d, &selected, &paint), away, Mods::default()), + _ => cursor_at(id, &d, away), + }; for (id, c) in [ ("lasso", Cursor::Lasso), (addons::enclosing_lasso::ID, Cursor::Lasso), @@ -646,7 +654,7 @@ mod cursor_tests { ("symbolStyler", Cursor::SymbolStyler), ("mesh", Cursor::MeshAdd), ] { - assert_eq!(cursor_at(id, &d, away), c, "{id}"); + assert_eq!(cursor_of(id), c, "{id}"); assert_eq!(c.precise(), Cursor::Crosshair, "{id}"); // The Blob Brush's and the Eraser's dot is drawn: their tip outline goes round it. The // Live Paint Bucket's bucket is drawn. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e309f72409..163b56d661 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1041,7 +1041,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; double-click, or Alt-click on any point but an end of an open path, toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). An Alt-click on an end of a selected open path makes that end current without changing it, so the next click goes on from it, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-shapes-with-the-curvature-tool.html) describes for continuing a path; an Alt-click that starts a new path places a corner point, as later Alt-clicks do. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. A filled Pencil path keeps a stroke of None (#1804, part of #1679). While a Pencil or Paintbrush stroke is drawn back to within Close Paths When Ends Are Within of its start, the cursor shows the closing ring and a release there closes the path, 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 for the Pencil. The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). Blob Brush and Eraser Tool Options' Size is Fixed or Pressure with a Variation (#4590, #3961): with Pressure a pen stroke is Size − Variation wide where it presses lightest and Size + Variation where it presses hardest, and a mouse stroke keeps the Size. With the Blob Brush, Shift held during a drag keeps the stroke on the horizontal or vertical line through the press point, whichever is nearer to the pointer (90° steps from the Constrain Angle preference), and the points dragged since Shift went down move to the other line when the pointer crosses to it; the points dragged while Shift is up follow the pointer. - With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line and, with a path selected that it can smooth, the dashed band it smooths, without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down. + With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool (Not Allowed with no path selected that it can act on), the drag shows a thin line and, with a path selected that it can smooth, the dashed band it smooths, without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down. 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). A path the Smooth tool changes stays selected whole, and the anchor points and segments selected on it with Direct Selection are deselected. Selected paths the drag does not change keep their selected anchors, and undo restores the earlier selection (`tests/draw2.rs`, `smoothing_a_path_drops_its_direct_selected_anchors`). While the Smooth tool drags with a path selected that it can smooth, a dashed outline shows the band it smooths: everything within its radius of the drag (12 screen pixels, VectorCraft's own value), round at both ends; Alt smoothing with the Pencil, Paintbrush and Blob Brush shows the same band. 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. @@ -1118,6 +1118,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Pencil's and the Paintbrush's Fidelity is in screen pixels, the same at any zoom: their Tool Options give it in px, from 0.01 px on the logarithmic slider to 20 px and up to 100 px in the field, and the tool sends `path.freehand` the value in points at the current zoom (#5729). The Smooth tool's and the Blob Brush's Fidelity stays in points. `path.freehand` and `brush.freehand` take a Fidelity of up to 100 / 0.0313 pt (about 3194.89 pt), the 100 px field maximum at the lowest zoom (3.13 %), and reject a value outside their range with an error, so a Pencil or Paintbrush stroke drawn zoomed out keeps the Fidelity set in pixels (engine tests `pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out` and `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`). Each drag of the Smooth tool smooths the stretch of a selected path in its reach along its length before refitting it within Fidelity, so a wobble wider than Fidelity is smoothed out and another drag over the same place smooths it further, as [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) describes (`path.smoothRegion`). The smoothing spread (a third of the reach) and the rule that each refit stays within twice the distance the smoothing moved the path, so repeated drags do not drift from it, are VectorCraft's own. A drag over the whole of a closed path smooths it across its first anchor too, so a bump there is smoothed like any other and no corner is left; Object › Path › Smooth smooths a closed path across its first anchor the same way. + The Smooth tool shows the Not Allowed cursor while no path is selected that it can act on (an editable path, not a guide, with three or more anchors on one of its subpaths), and the release of a drag then sends no command and changes nothing; [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) has the path selected first. A selected path anywhere on the canvas gives the Smooth cursor wherever the pointer is. The Pencil with Alt key toggles to Smooth Tool on and the Blob Brush show Not Allowed the same way when Alt gives them the Smooth tool; the Paintbrush keeps its closing cursor, since its release with Alt closes the stroke. Use Precise Cursors leaves Not Allowed as it is. This cursor rule is VectorCraft's own (tools `smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `borrowed_smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `smoothing_release_sends_nothing_without_a_selected_path_to_smooth`; engine `use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer`). Before a press that draws a new path, the Pencil cursor shows a small star, as [Draw freeform paths with the Pencil tool](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-freeform-paths-with-the-pencil-tool.html) describes; over a selected path within Edit selected paths' distance, which the press continues from an end or redraws, the star is not shown ([Reshape paths with the Pencil tool](https://web.archive.org/web/20260106004407/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/reshape-paths-with-the-pencil-tool.html)). Keep selected in the Pencil and Paintbrush Tool Options is on by default: while it is on, a stroke selects the path it draws or continues and replaces the earlier selection. While it is off, a new stroke leaves nothing selected, the earlier selection included, as the reference application starts the stroke by dropping it (#5752); a continued or redrawn path is deselected while the rest of the selection stays (`path.freehand` `keepSelected`). With two open paths selected, a Pencil stroke from an end of one that ends within Edit selected paths' distance of an end of the other connects them into one path with the first path's appearance, in one undo step, as [Connect two paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/connect-two-paths-with-the-pencil-tool.html) describes (`path.freehand` `connect`). From 3429bbc76a6a9ee363756969e8d19aa4a5d335f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 14:16:15 -0800 Subject: [PATCH 02/30] Save, Export for Screens: warnings name an option the file doesn't read The save commands' warnings now follow an export's .ai rules: a legacy .ai file (aiVersion "7") reads only its own options, and the other .ai and .ait files don't read outlineText, hiddenObjects, transparentImages or preserveEditing. They name any option given beside the path that the file doesn't read, not only compress, version, preview and precision, and any such key in options. The bytes a save writes don't change. The Save Options dialog sends a .ai save only the options the chosen version reads, so a save from the dialog gets none of these warnings. When the document's own file is already a .ai, the .ai options that a save doesn't give and its file doesn't read keep their remembered values, so the dialog still shows them; the save doesn't write them, and a retry in another folder doesn't send them. Export for Screens and Export Assets already accepted a key in a format row or in a format's settings that only another format takes. Their warnings now name it as given (formats[0].quality, settings.png.quality). A key in a format's settings is named only when no row of that format, as the export writes it, reads it. The options of an exportPreset, and for assets.export the settings the document keeps, get no warning. Docs: params text of document.save, document.exportForScreens and assets.export; docs/mcp.md; docs/target-app-parity.md. --- crates/engine/src/cmd/assets.rs | 4 +- crates/engine/src/cmd/fileio.rs | 2 +- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/export.rs | 27 +++-- crates/engine/src/cmd/fileio/save.rs | 79 +++++++++++++-- crates/engine/src/cmd/fileio/screens.rs | 94 ++++++++++++++++-- crates/engine/src/cmd/fileio/tests.rs | 4 +- .../engine/src/cmd/fileio/tests_exportas.rs | 99 +++++++++++++++++++ crates/engine/src/cmd/fileio/tests_screens.rs | 90 +++++++++++++++++ crates/engine/src/cmd/recovery.rs | 1 + crates/mcp/src/tests/fileio.rs | 10 +- crates/ui-egui/src/dialogs/save_options.rs | 10 +- .../ui-egui/src/dialogs/save_options/tests.rs | 27 +++++ crates/ui-egui/src/dialogs/tests_screens.rs | 33 +++++++ docs/mcp.md | 18 +++- docs/target-app-parity.md | 2 +- 16 files changed, 452 insertions(+), 50 deletions(-) diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 1ebde5c026..e30e6efcdc 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Export Assets", [], None, - "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", + "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64; warnings name a key in the rows or settings given that only another format takes, not one in the remembered settings). Leaves the remembered settings as they are (assets.settings.set changes them)", has_doc, export ), @@ -429,7 +429,7 @@ fn export(s: &mut Session, p: &Value) -> Result { } q.insert("assets".into(), json!(all)); } - export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e)) + export_screens(s, &Value::Object(q), p).map_err(|e| of_cmd(C, e)) } fn settings_set(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 9154ce7ee5..97f957ed0e 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -92,7 +92,7 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, file_reads, leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 5abe3e879c..e7656bb6d0 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,