diff --git a/ROADMAP.md b/ROADMAP.md index 06787d5dc..49a357e87 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 483611a01..764c2e158 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -363,7 +363,12 @@ fn reflect(s: &mut Session, p: &Value) -> Result { let t = -n.as_f64().unwrap_or(90.0).to_radians(); Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t) } - _ => Affine::scale_non_uniform(-1.0, 1.0), + None | Some(Value::Null) => Affine::scale_non_uniform(-1.0, 1.0), + Some(Value::String(a)) if a == "vertical" => Affine::scale_non_uniform(-1.0, 1.0), + Some(v) => { + let given = v.as_str().map_or_else(|| v.to_string(), str::to_string); + return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`"))); + } }; apply_transform(s, "Reflect", ids, about(o, m), p) } @@ -372,8 +377,8 @@ fn shear(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let o = origin_of(s, p, &ids)?; let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan(); - let m = - if str_param(p, "axis") == Some("vertical") { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) }; + let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical"); + let m = if vertical { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) }; apply_transform(s, "Shear", ids, about(o, m), p) } @@ -936,14 +941,27 @@ impl Session { /// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds. fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result { - match str_param(p, "bounds") { + match choice(p, cmd, "bounds", &["preview", "geometric"])? { None => Ok(s.prefs.use_preview_bounds), - Some("preview") => Ok(true), - Some("geometric") => Ok(false), - Some(b) => Err(bad(cmd, format!("bounds must be \"preview\" or \"geometric\", not `{b}`"))), + Some(b) => Ok(b == "preview"), } } +/// Param `key` of the Align commands and Shear: one of `values`, or `None` when it is missing or +/// null. Any other value is an error that lists `values`. +fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result> { + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + match v.as_str() { + Some(s) if values.contains(&s) => Ok(Some(s)), + _ => Err(bad(cmd, format!("`{key}` must be one of {}", values.join(", ")))), + } +} + +/// `horizontal` and `vertical` of `object.align` and `object.distribute`, each checked by [`choice`]. +fn direction<'a>(p: &'a Value, cmd: &str) -> Result<(Option<&'a str>, Option<&'a str>)> { + Ok((choice(p, cmd, "horizontal", &["left", "center", "right"])?, choice(p, cmd, "vertical", &["top", "center", "bottom"])?)) +} + /// `ids` with their bounds (`preview`: visual bounds). fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> { ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect() @@ -992,9 +1010,12 @@ fn key_root(s: &Session, ids: &[NodeId]) -> Option { fn align(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let preview = preview_bounds(s, p, "object.align")?; + let (h, v) = direction(p, "object.align")?; + choice(p, "object.align", "to", &["selection", "artboard", "key"])?; + if h.is_none() && v.is_none() { + return Err(bad("object.align", "give horizontal or vertical")); + } let r = reference_rect(s, p, &ids, preview)?; - let h = str_param(p, "horizontal"); - let v = str_param(p, "vertical"); let key = key_root(s, &ids); let moves: Vec<(NodeId, Vec2)> = { let d = &s.doc()?.doc; @@ -1037,7 +1058,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let preview = preview_bounds(s, p, "object.distribute")?; let mut items = items_bounds(&s.doc()?.doc, &ids, preview); - let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match (str_param(p, "horizontal"), str_param(p, "vertical")) { + let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? { (Some(h), _) => ( true, match h { @@ -1089,7 +1110,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result { fn distribute_spacing(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; - let horiz = str_param(p, "axis") != Some("vertical"); + let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical"); let preview = preview_bounds(s, p, "object.distributeSpacing")?; let mut items = items_bounds(&s.doc()?.doc, &ids, preview); items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); @@ -1099,7 +1120,11 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { }; let size = |r: &Rect| if horiz { r.width() } else { r.height() }; let start = |r: &Rect| if horiz { r.x0 } else { r.y0 }; - let spacing = p.get("spacing").and_then(Value::as_f64).filter(|g| g.is_finite()); + let spacing = p + .get("spacing") + .filter(|v| !v.is_null()) + .map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad("object.distributeSpacing", "`spacing` must be a number"))) + .transpose()?; let gap = match spacing { Some(g) => g, None => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 34acb9226..9df250811 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -381,6 +381,65 @@ fn clipping_and_compound() { assert_eq!(s.doc().unwrap().selection.objects, vec![b]); } +/// `object.align` without `horizontal` or `vertical` (an unknown key such as `align` counts as +/// neither) returns an error: nothing moves, and the undo and redo stacks stay as they were. +#[test] +fn align_without_a_direction_is_refused() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let stacks = |s: &Session| (s.doc().unwrap().history.undo.len(), s.doc().unwrap().history.redo.len()); + let before = stacks(&s); + for p in [json!({}), json!({"align": "left"}), json!({"to": "artboard"}), json!({"horizontal": null, "vertical": null})] { + let e = s.execute("object.align", &p).expect_err(&p.to_string()); + assert_eq!(e.to_string(), "invalid parameters for `object.align`: give horizontal or vertical", "{p}"); + } + assert_eq!(stacks(&s), before, "no undo step, and the redo step is kept"); + assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 50.0, "nothing moved"); +} + +/// Align, Distribute and Distribute Spacing return an error that lists the choices of a param +/// given a value outside them: nothing moves and no undo step is recorded. A null value counts as +/// not given, and Distribute Spacing without an `axis` spaces the objects horizontally. +#[test] +fn align_and_distribute_reject_bad_params_without_changing_state() { + let mut s = session(); + let ids: Vec = [0.0, 50.0, 200.0].into_iter().map(|x| rect(&mut s, x, x / 2.0, 10.0, 10.0)).collect(); + s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); + let bounds = |s: &Session| ids.iter().map(|id| s.doc().unwrap().doc.node(*id).unwrap().geometric_bounds().unwrap()).collect::>(); + let before = bounds(&s); + let undo = s.doc().unwrap().history.undo.len(); + let h = "`horizontal` must be one of left, center, right"; + let v = "`vertical` must be one of top, center, bottom"; + let to = "`to` must be one of selection, artboard, key"; + for (cmd, p, msg) in [ + ("object.align", json!({"horizontal": "middle"}), h), + ("object.align", json!({"vertical": "centre"}), v), + ("object.align", json!({"horizontal": true}), h), + ("object.align", json!({"horizontal": "left", "to": "page"}), to), + ("object.align", json!({"horizontal": "left", "to": 0}), to), + ("object.align", json!({"horizontal": "left", "bounds": false}), "`bounds` must be one of preview, geometric"), + ("object.align", json!({"horizontal": "left", "vertical": "middle"}), v), + ("object.distribute", json!({"horizontal": "middle"}), h), + ("object.distribute", json!({"vertical": "centre"}), v), + ("object.distribute", json!({"horizontal": "left", "vertical": "middle"}), v), + ("object.distributeSpacing", json!({"axis": "diagonal"}), "`axis` must be one of horizontal, vertical"), + ("object.distributeSpacing", json!({"axis": "vertical", "spacing": "5"}), "`spacing` must be a number"), + ] { + let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")); + assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {msg}")); + } + assert_eq!(bounds(&s), before, "nothing moved"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + s.execute("object.align", &json!({"horizontal": null, "vertical": "top", "to": null, "bounds": null})).unwrap(); + assert!(bounds(&s).iter().all(|b| b.y0 == 0.0), "aligned to the top"); + s.execute("object.distributeSpacing", &json!({"axis": null, "spacing": null})).unwrap(); + assert_eq!(bounds(&s).iter().map(|b| b.x0).collect::>(), [0.0, 100.0, 200.0], "equal horizontal gaps"); +} + #[test] fn align_left() { let mut s = session(); diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests_xform.rs index f5798ffb6..4984efece 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests_xform.rs @@ -104,6 +104,37 @@ fn reflect_and_shear_tools() { assert!((b.width() - 150.0).abs() < 1e-6, "{b:?}"); } +/// Reflect and Shear return an error for an `axis` they don't take (Reflect's names it and also +/// takes an angle): nothing moves and no undo step is recorded. A null `axis` counts as not given: Reflect +/// then reflects across the vertical axis and Shear shears along the horizontal one. +#[test] +fn reflect_and_shear_reject_unknown_axes_without_changing_state() { + let mut s = session(); + let a = rect(&mut s, 10.0, 20.0, 30.0, 10.0); + let n = undo_len(&s); + let reflect = "axis must be \"vertical\", \"horizontal\" or an angle in degrees"; + let shear = "`axis` must be one of horizontal, vertical"; + for (cmd, p, msg) in [ + ("object.reflect", json!({"axis": "diagonal", "origin": [0, 0]}), format!("{reflect}, not `diagonal`")), + ("object.reflect", json!({"axis": "45", "origin": [0, 0]}), format!("{reflect}, not `45`")), + ("object.reflect", json!({"axis": true, "origin": [0, 0]}), format!("{reflect}, not `true`")), + ("object.shear", json!({"angle": 30, "axis": "diagonal"}), shear.to_string()), + ("object.shear", json!({"angle": 30, "axis": 90}), shear.to_string()), + ] { + let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")); + assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {msg}")); + } + assert!(close(bounds(&s, a), Rect::new(10.0, 20.0, 40.0, 30.0)), "nothing moved: {:?}", bounds(&s, a)); + assert_eq!(undo_len(&s), n, "no undo step"); + s.execute("object.reflect", &json!({"axis": null, "origin": [0, 0]})).unwrap(); + assert!(close(bounds(&s, a), Rect::new(-40.0, 20.0, -10.0, 30.0)), "across the vertical axis: {:?}", bounds(&s, a)); + s.execute("object.reflect", &json!({"axis": 0, "origin": [0, 0]})).unwrap(); + assert!(close(bounds(&s, a), Rect::new(-40.0, -30.0, -10.0, -20.0)), "an angle still reflects: {:?}", bounds(&s, a)); + s.execute("object.shear", &json!({"angle": 45, "axis": null, "origin": [0, 0]})).unwrap(); + let b = bounds(&s, a); + assert!((b.y0 + 30.0).abs() < 1e-6 && (b.y1 + 20.0).abs() < 1e-6 && b.width() > 30.0, "along the horizontal axis: {b:?}"); +} + #[test] fn distort_command_maps_corners() { let mut s = session(); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index c383051f7..ce5288526 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -321,6 +321,9 @@ fn structured_junk() { ("object.setBounds", json!({"width": 0, "height": 0})), ("object.setBounds", json!({"width": -10, "height": 1e308, "reference": 99})), ("object.distributeSpacing", json!({"axis": "horizontal", "spacing": -1e308})), + ("object.distributeSpacing", json!({"axis": "vertical", "spacing": 0})), + ("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard", "bounds": "preview"})), + ("object.distribute", json!({"vertical": "center", "bounds": "geometric"})), ("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})), ("artboard.setProps", json!({"index": 0, "width": 1e300, "height": 1e-300, "scaleArt": true, "strokes": true, "corners": true})), ("artboard.setProps", json!({"index": 0, "x": -1e308, "width": 0, "height": 1, "scaleArt": true, "patterns": true})), diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 9ae42a4af..35d04fdf0 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -138,7 +138,7 @@ pub fn tool_definitions() -> Vec { tool( "run_command", "Run command", - "Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"align\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).", + "Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"horizontal\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).", obj( json!({"command": string("Command id, e.g. shape.rectangle, object.group, paint.setFill"), "params": {"type": "object", "description": "Command parameters"}}), &["command"], diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 513d77e51..0240988bc 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3384,6 +3384,7 @@ theme Appearance Erscheinungsbild @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art oder type @msg artboard {0} does not exist Zeichenfläche {0} ist nicht vorhanden @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard in diesem Maßstab misst die Zeichnung {w} × {h} pt, mehr als die Arbeitsfläche fasst: Wählen Sie einen kleineren Maßstab oder passen Sie sie an die Zeichenfläche an +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis muss "vertical", "horizontal" oder ein Winkel in Grad sein, nicht `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black oder eine Farbe wie "#ff8800" @msg bad color {v} ungültige Farbe {v} @msg bad colour `{hex}` (#rrggbb) ungültige Farbe `{hex}` (#rrggbb) @@ -3426,6 +3427,7 @@ theme Appearance Erscheinungsbild @msg freeform line {i} must be an array of point indices Freiformlinie {i} muss ein Array von Punktindizes sein @msg give both `start` and `end` [x, y] (or neither) geben Sie sowohl `start` als auch `end` [x, y] an (oder keines von beiden) @msg give corners of the path: anchors between two straight sides geben Sie Ecken des Pfads an: Ankerpunkte zwischen zwei geraden Seiten +@msg give horizontal or vertical geben Sie horizontal oder vertical an @msg give the path `id` and the width point `index` geben Sie die `id` des Pfads und den `index` des Breitenpunkts an @msg guides are locked die Hilfslinien sind gesperrt @msg image '{key}' has no pixels and was left out das Bild '{key}' hat keine Pixel und wurde ausgelassen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index dd147da78..367bf3634 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3485,6 +3485,7 @@ axis Both Ambos @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art o type @msg artboard {0} does not exist la mesa de trabajo {0} no existe @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard a esta escala el dibujo mide {w} × {h} pt, más de lo que cabe en el lienzo: elige una escala menor o ajústalo a la mesa de trabajo +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis debe ser "vertical", "horizontal" o un ángulo en grados, no `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" fondo {other}: transparent, white, black o un color como "#ff8800" @msg bad color {v} color no válido {v} @msg bad colour `{hex}` (#rrggbb) color no válido `{hex}` (#rrggbb) @@ -3527,6 +3528,7 @@ axis Both Ambos @msg freeform line {i} must be an array of point indices la línea de forma libre {i} debe ser una lista de índices de puntos @msg give both `start` and `end` [x, y] (or neither) indica `start` y `end` [x, y] (o ninguno de los dos) @msg give corners of the path: anchors between two straight sides indica esquinas del trazado: puntos de ancla entre dos lados rectos +@msg give horizontal or vertical indica horizontal o vertical @msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura @msg guides are locked las guías están bloqueadas @msg image '{key}' has no pixels and was left out la imagen '{key}' no tiene píxeles y se omitió diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 12d62d3ac..22921e24e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3482,6 +3482,7 @@ axis Both Les deux @msg antiAlias `{id}`: none, art or type antiAlias `{id}` : none, art ou type @msg artboard {0} does not exist le plan de travail {0} n’existe pas @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard à cette échelle, le dessin mesure {w} × {h} pt, plus que ne peut contenir la zone de travail : choisissez une échelle plus petite ou ajustez-le au plan de travail +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis doit être "vertical", "horizontal" ou un angle en degrés, pas `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" arrière-plan {other} : transparent, white, black ou une couleur comme "#ff8800" @msg bad color {v} couleur non valide {v} @msg bad colour `{hex}` (#rrggbb) couleur non valide `{hex}` (#rrggbb) @@ -3524,6 +3525,7 @@ axis Both Les deux @msg freeform line {i} must be an array of point indices la ligne de forme libre {i} doit être un tableau d’indices de points @msg give both `start` and `end` [x, y] (or neither) indiquez à la fois `start` et `end` [x, y] (ou aucun des deux) @msg give corners of the path: anchors between two straight sides indiquez des angles du tracé : des points d’ancrage entre deux côtés droits +@msg give horizontal or vertical indiquez horizontal ou vertical @msg give the path `id` and the width point `index` indiquez l’`id` du tracé et l’`index` du point de largeur @msg guides are locked les repères sont verrouillés @msg image '{key}' has no pixels and was left out l’image « {key} » n’a aucun pixel et a été ignorée diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e693ee6db..ce2fd9c6b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3485,6 +3485,7 @@ axis Both Entrambi @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art o type @msg artboard {0} does not exist la tavola da disegno {0} non esiste @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard a questa scala il disegno misura {w} × {h} pt, più di quanto possa contenere l'area di disegno: scegli una scala più piccola o adattalo alla tavola da disegno +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis deve essere "vertical", "horizontal" o un angolo in gradi, non `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" sfondo {other}: transparent, white, black o un colore come "#ff8800" @msg bad color {v} colore non valido {v} @msg bad colour `{hex}` (#rrggbb) colore non valido `{hex}` (#rrggbb) @@ -3527,6 +3528,7 @@ axis Both Entrambi @msg freeform line {i} must be an array of point indices la linea a forma libera {i} deve essere un array di indici di punti @msg give both `start` and `end` [x, y] (or neither) specifica sia `start` sia `end` [x, y] (o nessuno dei due) @msg give corners of the path: anchors between two straight sides specifica angoli del tracciato: punti di ancoraggio tra due lati diritti +@msg give horizontal or vertical specifica horizontal o vertical @msg give the path `id` and the width point `index` specifica l'`id` del tracciato e l'`index` del punto di larghezza @msg guides are locked le guide sono bloccate @msg image '{key}' has no pixels and was left out l'immagine '{key}' non ha pixel ed è stata omessa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9a1339899..5f0bd7bd2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3528,6 +3528,8 @@ theme Appearance 外観 @msg nothing to align 整列するものがありません @msg select a clip group クリップグループを選択してください @msg select two or more objects to distribute 分布させるオブジェクトを 2 つ以上選択してください +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis には "vertical"、"horizontal"、度単位の角度のいずれかを指定してください (`{v}` は指定できません) +@msg give horizontal or vertical horizontal または vertical を指定してください @msg selection has no bounds 選択範囲に境界がありません @msg the top object must be a path, compound path or text object to use as a clipping mask クリッピングマスクとして使うには、最前面のオブジェクトがパス、複合パス、テキストオブジェクトのいずれかである必要があります @msg no target object has an opacity mask: select one or give `id` 不透明マスクのあるターゲットオブジェクトがありません: 該当するオブジェクトを選択するか、`id` を指定してください diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e805d0aff..c50e420d2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3481,6 +3481,7 @@ axis Both Оба @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art или type @msg artboard {0} does not exist монтажная область {0} не существует @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard при этом масштабе рисунок имеет размер {w} × {h} пт, что больше холста: выберите меньший масштаб или впишите его в монтажную область +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis должно быть "vertical", "horizontal" или углом в градусах, а не `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black или цвет, например "#ff8800" @msg bad color {v} недопустимый цвет {v} @msg bad colour `{hex}` (#rrggbb) недопустимый цвет `{hex}` (#rrggbb) @@ -3523,6 +3524,7 @@ axis Both Оба @msg freeform line {i} must be an array of point indices произвольная линия {i} должна быть массивом индексов точек @msg give both `start` and `end` [x, y] (or neither) укажите и `start`, и `end` [x, y] (или ни одного) @msg give corners of the path: anchors between two straight sides укажите углы контура: опорные точки между двумя прямыми сторонами +@msg give horizontal or vertical укажите horizontal или vertical @msg give the path `id` and the width point `index` укажите `id` контура и `index` точки ширины @msg guides are locked направляющие заблокированы @msg image '{key}' has no pixels and was left out изображение '{key}' не имеет пикселей и было пропущено diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index fe1c968b6..f9464ed0d 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -923,6 +923,37 @@ fn complete_languages_translate_every_message() { } } +/// The parameter errors of Align, Distribute Spacing, Reflect and Shear, whose text the scan above +/// doesn't read, are translated whole in each language of [`COMPLETE_MESSAGES`]. +#[test] +fn align_reflect_and_shear_param_errors_are_translated() { + use serde_json::json; + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for x in [0, 50] { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap(); + } + s.execute("select.all", &json!({})).unwrap(); + for (cmd, p) in [ + ("object.align", json!({})), + ("object.align", json!({"horizontal": "middle"})), + ("object.align", json!({"vertical": "centre"})), + ("object.align", json!({"horizontal": "left", "to": "page"})), + ("object.align", json!({"horizontal": "left", "bounds": "visual"})), + ("object.distributeSpacing", json!({"axis": "diagonal"})), + ("object.distributeSpacing", json!({"spacing": "5"})), + ("object.reflect", json!({"axis": "diagonal"})), + ("object.shear", json!({"axis": "diagonal"})), + ] { + let e = s.execute(cmd, &p).unwrap_err(); + let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{cmd} {p}: {e}") }; + for code in COMPLETE_MESSAGES { + let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); + assert!(!shown.contains(msg.as_str()), "{code}: {shown}"); + } + } +} + /// Languages whose catalogs cover every status and error message. const COMPLETE_MESSAGES: &[&str] = &["de", "es", "fr", "it", "ja", "ru", "uk"]; diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 370f820e3..03c2bd9e2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3468,6 +3468,7 @@ axis Both Обидві @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art або type @msg artboard {0} does not exist монтажна область {0} не існує @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard при цьому масштабі малюнок має розмір {w} × {h} пт, що більше полотна: виберіть менший масштаб або впишіть його в монтажну область +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis має бути "vertical", "horizontal" або кутом у градусах, а не `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black або колір, наприклад "#ff8800" @msg bad color {v} неприпустимий колір {v} @msg bad colour `{hex}` (#rrggbb) неприпустимий колір `{hex}` (#rrggbb) @@ -3510,6 +3511,7 @@ axis Both Обидві @msg freeform line {i} must be an array of point indices довільна лінія {i} має бути масивом індексів точок @msg give both `start` and `end` [x, y] (or neither) вкажіть `start`, `end` [x, y] (або жодного) @msg give corners of the path: anchors between two straight sides вкажіть кути контуру: опорні точки між двома прямими сторонами +@msg give horizontal or vertical вкажіть horizontal або vertical @msg give the path `id` and the width point `index` вкажіть `id` контуру та `index` точки ширини @msg guides are locked напрямні заблоковані @msg image '{key}' has no pixels and was left out зображення '{key}' не має пікселів і було пропущено diff --git a/docs/mcp.md b/docs/mcp.md index 5bcef1b42..71d00bba0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -435,7 +435,7 @@ Long-tail commands: ```json {"name":"list_commands","arguments":{"filter":"align"}} {"name":"run_command","arguments":{"command":"select.all"}} -{"name":"run_command","arguments":{"command":"object.align","params":{"align":"left"}}} +{"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left"}}} {"name":"run_command","arguments":{"command":"object.group"}} {"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/art.pdf","range":"1, 3","compatibility":"1.5"}}} {"name":"run_command","arguments":{"command":"document.pdfSettings","params":{"marks":{"trim":true},"includeDocument":true}}} @@ -1506,6 +1506,11 @@ Effects off a 100 pt wide rectangle with a 10 pt stroke set to `width: 220` gets {"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","bounds":"preview"}}} ``` +`object.align` needs `horizontal` or `vertical`. The three Align commands, `object.reflect` and `object.shear` return +an error that lists the choices of a `horizontal`, `vertical`, `to`, `axis` or `bounds` param given another value +(`object.reflect` also takes an angle and names the value given), and one for a `spacing` that is not a number; nothing +moves. + ## Selection preferences The Selection & Anchor Display and General preferences apply to `pointer_gesture` as they do to the mouse: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1e68fc427..da1e3b0a4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -405,6 +405,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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). 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; the on-canvas pie widget is still to come). 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). - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). + Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).