Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 898c4f7677 Docs: ROADMAP for the 2026-10-10 fixes; Other Library lists color books
The ROADMAP's last-updated line names this batch's fixes and the two
community PRs that didn't add themselves (#792, #765); the platforms
paragraph says Windows starts on Direct3D 12 and loads no Vulkan driver
unless asked (#806); the guides row has ruler guides with Smart Guides
on and the hover labels (#812). Window › Swatch Libraries › Other
Library's description lists .acb color books (#831).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:52:27 +07:00
BFlat 6b42a9100c Merge pull request #887 from storytold/fix/812-hover-labels
Part of #812: Smart Guides label an object's centre and anchors on hover
2026-10-10 10:46:53 +07:00
BFlat 1b398978ba Merge pull request #884 from storytold/fix/827-raster-effect-blend
Fix #827: shadows and glows keep their blend modes in PDF
2026-10-10 10:46:45 +07:00
BFlat 449387a62e Merge pull request #883 from storytold/fix/812-guides-snap
Part of #812: ruler guides pull drawn points with Smart Guides on
2026-10-10 10:46:28 +07:00
BFlat 0782972b88 Merge pull request #882 from storytold/fix/785-step-references
Part of #785: run and command_batch steps can use earlier results
2026-10-10 10:46:10 +07:00
BFlat 07708f6cca Merge pull request #881 from storytold/fix/861-open-error-dialog
Part of #861: a file the user can't open is reported in a dialog
2026-10-10 10:46:01 +07:00
BFlat 5b26fd4d01 Merge pull request #879 from storytold/fix/831-acb-color-books
Fix #831: color books (.acb) open as swatch libraries
2026-10-10 10:45:54 +07:00
BFlat a05a6d06bf Merge pull request #878 from storytold/fix/833-isolation-locks-outside
Fix #833: isolation mode locks and dims the art around the group
2026-10-10 10:45:48 +07:00
BFlat fd59778c8f Merge pull request #877 from storytold/fix/775-properties-paragraph
Fix #775: the Properties panel's Paragraph section justifies and hyphenates
2026-10-10 10:45:43 +07:00
BFlat 06d61296f5 Merge pull request #876 from storytold/fix/789-align-to-everywhere
Fix #789: every align button aligns to the Align To choice
2026-10-10 10:45:36 +07:00
BFlat fb4b84c4b9 Merge pull request #875 from storytold/fix/835-gradient-stop-recent
Fix #835: a recent colour recolours the selected gradient stop
2026-10-10 10:45:30 +07:00
BFlat 9c0eccb067 Merge pull request #874 from storytold/fix/862-stop-popover-beside-art
Fix #862: the gradient stop popover opens beside the art
2026-10-10 10:45:22 +07:00
BFlat 2b01ebcc9e Merge pull request #873 from storytold/fix/826-rotated-rulers
Fix #826: rulers cover their length in a rotated view
2026-10-10 10:45:15 +07:00
BFlat 447fe10edf Merge pull request #872 from storytold/fix/839-sfw-pixel-size
Fix #839: Save for Web accepts a typed width or height
2026-10-10 10:45:09 +07:00
BFlat 9cc0ca866d Merge pull request #871 from storytold/fix/824-artboard-label
Fix #824: the Artboard tool names its artboard once
2026-10-10 10:45:03 +07:00
BFlat 56b3119288 Merge pull request #870 from storytold/fix/806-no-vulkan-on-windows
Fix #806: Windows doesn't load Vulkan drivers unless asked
2026-10-10 10:44:58 +07:00
BFlat 0b249c5257 Merge pull request #869 from storytold/fix/758-skip-hopeless-prune
Part of #758: .ai import doesn't weigh text objects when the layers can't be used
2026-10-10 10:44:52 +07:00
BFlat c1bcd0afeb Merge pull request #863 from storytold/fix/755
Fix #755: blurred type no longer shifts as the view moves
2026-10-10 10:44:41 +07:00
BFlat 5d5db434ea Merge pull request #860 from storytold/fix/830-app-quit-pending
Fix #830: the control channel's app.quit says when it asks first
2026-10-10 10:44:34 +07:00
BFlat 84ae8e90c3 Merge pull request #859 from storytold/fix/828-effect-dropdowns
Fix #828: Offset Path's Joins and Zig Zag's Points are dropdowns
2026-10-10 10:44:27 +07:00
BFlat 71d8881186 Merge pull request #753 from storytold/feat/637-type-from-text-document
Part of #637: area type, threaded type and type on a path from the text document
2026-10-10 10:44:20 +07:00
bflatasticandClaude Opus 5.5 fdb7d7a183 Part of #812: Smart Guides label an object's centre and anchors on hover
With Smart Guides on, hovering the Selection tool near an object's centre
or one of its anchors now labels it ("center", "anchor"), as
Illustrator does, and a press there grabs the object by that point, so
the point follows the pointer and snaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:28:38 +07:00
bflatasticandClaude Opus 5.5 99671cabd8 Fix #827: shadows and glows keep their blend modes in PDF
A raster effect painted below its object (Drop Shadow, Outer Glow) is
written as an image under the object's vector art. The image was
rendered over nothing and placed with Normal blending, so a Screen glow
or a Multiply shadow came out as Normal in the PDF. Each image is now
composited with its effects' blend mode, one image per mode when an
object's shadows and glows use several.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:28:04 +07:00
bflatasticandClaude Opus 5.5 522c1dabd6 Part of #812: ruler guides pull drawn points with Smart Guides on
Drawing with Smart Guides on ignored the ruler guides: only with Smart
Guides off did Snap to Point use them (a vertical guide seemed to work
when it lay on the artboard's centre line, which Smart Guides follow).
With Snap to Point on, the ruler guides now pull drawn and dragged
points with Smart Guides on too, also while a point slides along a
construction guide or Shift's angle, as in Illustrator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:28:03 +07:00
bflatasticandClaude Opus 5.5 b767e850ed Part of #785: run and command_batch steps can use earlier results
A run (vectorcraft-cli run, the MCP command_batch tool) had no way to use
an id an earlier step returned. A parameter string that is exactly
$N.path or $last.path now takes that value from step N's result
(0-based), e.g. {"id":"$1.id"}; $$ starts a literal $, and a
reference to nothing fails the step. Objects given to a command are now
used as given: an ids element or id that isn't an object id (or an
unresolved reference) fails instead of the command acting on the
selection. text.setRangeStyle styles the selected type object when no
id is given, as text.setStyle does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:28:03 +07:00
22 changed files with 461 additions and 29 deletions

No files matched your search

+4 -4
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
## Where we are
@@ -42,7 +42,7 @@ since 2026-10-01.
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows renders through DX12 before Vulkan (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
@@ -98,7 +98,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- JSON-lines control channel with real egui pointer and keyboard injection.
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785).
- Actions panel that records and plays back commands.
- **UI:** Illustrator 2026 layout restyled to measured values:
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
@@ -227,7 +227,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker, Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740) | 2–4 |
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4 |
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
+8 -2
View File
@@ -269,17 +269,23 @@ fn run(args: &[String]) -> Result<(), String> {
} else if !matches!(steps.first(), Some(Step::Cmd(id, _)) if id == "file.new") {
h.ensure_document();
}
// Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`).
let mut results = vec![];
for step in steps {
match step {
let r = match step {
Step::Cmd(id, params) => {
let params = vectorcraft_engine::steps::resolve(&params, &results).map_err(|e| format!("{id}: {e}"))?;
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?;
emit(json!({"step": "cmd", "command": id, "result": r}))?;
r
}
Step::Export(path) => {
let r = h.call("app.export", json!({"path": path, "scale": scale})).map_err(|e| format!("export {path}: {e}"))?;
emit(json!({"step": "export", "result": r}))?;
r
}
}
};
results.push(r);
}
Ok(())
}
+7
View File
@@ -52,6 +52,13 @@ impl RasterFx {
pub fn is_below(&self) -> bool {
matches!(self, RasterFx::DropShadow { .. } | RasterFx::OuterGlow { .. })
}
/// The blend mode it paints with (shadows and glows).
pub fn mode(&self) -> Option<BlendMode> {
match self {
RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } => Some(*mode),
_ => None,
}
}
/// How far this effect reaches beyond `bounds`, what it paints around (Radial Blur reaches
/// further around larger objects).
pub fn outset(&self, bounds: Rect) -> f64 {
+15 -5
View File
@@ -366,13 +366,23 @@ pub fn color_value(v: &Value) -> Option<Color> {
/// Objects a command targets: explicit `ids` param or the selection.
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
if let Some(ids) = ids_param(p, "ids") {
return Ok(ids);
// Objects given are used as given: a value that isn't an object id fails rather than the
// command acting on the selection instead (#785).
let object_id = |v: &Value| v.as_u64().map(NodeId).ok_or_else(|| EngineError::Other(format!("object ids are non-negative integers, not {v}")));
match p.get("ids").filter(|v| !v.is_null()) {
Some(Value::Array(a)) => return a.iter().map(object_id).collect(),
Some(v) => return Err(EngineError::Other(format!("`ids` must be an array of object ids, not {v}"))),
None => {}
}
if let Some(id) = id_param(p, "id") {
return Ok(vec![id]);
match p.get("id").filter(|v| !v.is_null()) {
Some(v @ Value::Number(_)) => Ok(vec![object_id(v)?]),
// A step reference a batch couldn't resolve (other strings are other ids, such as an
// effect's).
Some(Value::String(r)) if r.starts_with('$') => {
Err(EngineError::Other(format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
}
_ => Ok(s.doc()?.selection.objects.clone()),
}
Ok(s.doc()?.selection.objects.clone())
}
pub(crate) fn ok() -> Result<Value> {
+46 -6
View File
@@ -15,7 +15,7 @@
use std::sync::Arc;
use serde_json::{Map, Value, json};
use vectorcraft_color::Paint;
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
use vectorcraft_doc::{
Appearance, AppearanceItem, ColorMode, Document, Effect, ImageBlob, ImageObject, Node, NodeId, NodeKind, RasterColorModel, StrokeLayer,
@@ -217,18 +217,55 @@ pub(crate) fn raster_fx(n: &Node) -> Vec<RasterFx> {
/// Remove the raster effects of `n` (its own and its fills' and strokes') and, with `members`, of
/// everything inside it.
pub(crate) fn strip_raster(n: &mut Node, members: bool) {
let keep = |e: &Effect| !effects::is_raster(&e.id);
retain_effects(n, members, &|e| !effects::is_raster(&e.id));
}
/// Keep the effects of `n` (its own and its fills' and strokes') that `keep` accepts and, with
/// `members`, of everything inside it.
fn retain_effects(n: &mut Node, members: bool, keep: &dyn Fn(&Effect) -> bool) {
n.appearance.effects.retain(keep);
for it in &mut n.appearance.items {
it.effects_mut().retain(keep);
}
if members {
for c in n.children_mut().into_iter().flatten() {
strip_raster(Arc::make_mut(c), true);
retain_effects(Arc::make_mut(c), true, keep);
}
}
}
/// The blend mode raster effect `e` paints with (`None`: not a raster effect, or one without).
fn effect_mode(e: &Effect) -> Option<BlendMode> {
effects::raster_effects(std::slice::from_ref(e)).first().and_then(RasterFx::mode)
}
/// The images of the shadows and outer glows of `n`, one per blend mode they use (in the order
/// the modes first come), each composited with its mode: rendered alone, over nothing, they can't
/// blend with the art below the object, so the image does.
fn below_images(d: &mut Document, n: &Node) -> Option<Vec<Node>> {
let mut modes: Vec<BlendMode> = vec![];
for mode in raster_fx(n).iter().filter_map(RasterFx::mode) {
if !modes.contains(&mode) {
modes.push(mode);
}
}
if modes.len() < 2 {
let mut image = raster_image(d, n, true, false)?;
image.blend = modes.first().copied().unwrap_or_default();
return Some(vec![image]);
}
modes
.into_iter()
.map(|mode| {
let mut only = n.clone();
retain_effects(&mut only, false, &|e| effect_mode(e).is_none_or(|m| m == mode));
let mut image = raster_image(d, &only, true, false)?;
image.blend = mode;
Some(image)
})
.collect()
}
/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image of `d`
/// rendered at the document's raster effects resolution, without `n`'s transparency (that stays
/// on the object). With `below` (they all paint below it: shadows and outer glows) the image holds
@@ -405,15 +442,18 @@ fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
return Some(walk(out, &g, which).unwrap_or(g));
}
// An image that can't be made leaves the object as it is (the writer reports its effects).
let image = raster_image(out, n, below, false)?;
let mut v = n.clone();
strip_raster(&mut v, false);
let mut m = if below {
let images = below_images(out, n)?;
let mut m = walk(out, &v, which).unwrap_or(v);
put_below(out, &mut m, image);
// Each goes in first: the last one put is the lowest.
for image in images.into_iter().rev() {
put_below(out, &mut m, image);
}
m
} else {
replace_with_image(v, image)
replace_with_image(v, raster_image(out, n, false, false)?)
};
walk_mask(out, &mut m, which);
Some(m)
+9 -2
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -420,7 +420,14 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setRangeStyle";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
// The type object given, else the one selected (#785).
let id = match p.get("id").filter(|v| !v.is_null()) {
Some(v) => v.as_u64().map(NodeId).ok_or_else(|| bad(C, format!("`id` must be an object id, not {v}")))?,
None => match super::typecmd::text_targets(s, &json!({}), C)?.as_slice() {
[one] => *one,
_ => return Err(bad(C, "missing `id`: give one, or select one type object")),
},
};
let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
if change.is_empty() {
return Err(bad(C, "nothing to change"));
+1
View File
@@ -9,6 +9,7 @@
pub mod cmd;
pub mod guard;
pub mod inspect;
pub mod steps;
mod tooling;
pub mod units;
+138
View File
@@ -0,0 +1,138 @@
//! References between the steps of a batch (`vectorcraft-cli run`, MCP `command_batch`): a
//! parameter string that is exactly `$N` or `$N.path` takes that value from the result of step `N`
//! (0-based, in the order the steps ran), and `$last…` from the step before. A path goes into
//! objects by key and into arrays by index: `$1.id`, `$2.ids.0`, `$last.ids[0]`. `$$` at the start
//! of a string stands for one `$` and is never a reference (text such as `$$1.99`). A string that
//! looks like a reference but names no value is an error, so a step never runs with a reference
//! left in it.
use serde_json::Value;
/// Most nesting [`resolve`] walks into: deeper parameters are left as they are.
const MAX_DEPTH: usize = 64;
/// `params` with every step reference replaced from `results` (the results of the steps run so
/// far, in order); an error names a reference that resolves to nothing.
pub fn resolve(params: &Value, results: &[Value]) -> Result<Value, String> {
resolve_at(params, results, 0)
}
fn resolve_at(v: &Value, results: &[Value], depth: usize) -> Result<Value, String> {
if depth > MAX_DEPTH {
return Ok(v.clone());
}
Ok(match v {
Value::String(s) => match s.strip_prefix("$$") {
Some(rest) => Value::String(format!("${rest}")),
None => match parse(s) {
Some((step, path)) => lookup(s, step, &path, results)?,
None => v.clone(),
},
},
Value::Array(a) => Value::Array(a.iter().map(|x| resolve_at(x, results, depth + 1)).collect::<Result<_, _>>()?),
Value::Object(o) => Value::Object(o.iter().map(|(k, x)| Ok((k.clone(), resolve_at(x, results, depth + 1)?))).collect::<Result<_, String>>()?),
_ => v.clone(),
})
}
/// Which step a reference names.
#[derive(Debug, PartialEq)]
enum Step {
Index(usize),
Last,
}
/// A path segment: an object key or an array index (`.0` is either, as the value decides).
#[derive(Debug, PartialEq)]
enum Seg {
Key(String),
Index(usize),
}
/// `$N…` or `$last…` → the step and the path after it; `None` for any other string.
fn parse(s: &str) -> Option<(Step, Vec<Seg>)> {
let rest = s.strip_prefix('$')?;
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
let (step, mut rest) = if digits > 0 {
let (n, rest) = rest.split_at_checked(digits)?;
(Step::Index(n.parse().ok()?), rest)
} else {
(Step::Last, rest.strip_prefix("last")?)
};
let mut path = vec![];
while !rest.is_empty() {
if let Some(r) = rest.strip_prefix('.') {
let len = r.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b'_').count();
let (key, r) = r.split_at_checked(len)?;
if key.is_empty() {
return None;
}
path.push(Seg::Key(key.to_string()));
rest = r;
} else {
let (n, r) = rest.strip_prefix('[')?.split_once(']')?;
path.push(Seg::Index(n.parse().ok()?));
rest = r;
}
}
Some((step, path))
}
fn lookup(text: &str, step: Step, path: &[Seg], results: &[Value]) -> Result<Value, String> {
let (i, found) = match step {
Step::Index(i) => (i, results.get(i)),
Step::Last => (results.len().saturating_sub(1), results.last()),
};
let mut cur = found.ok_or_else(|| format!("`{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $)"))?;
for seg in path {
let next = match (seg, cur) {
(Seg::Key(k), Value::Object(o)) => o.get(k),
(Seg::Key(k), Value::Array(a)) => k.parse::<usize>().ok().and_then(|n| a.get(n)),
(Seg::Index(n), Value::Array(a)) => a.get(*n),
_ => None,
};
cur = next.ok_or_else(|| format!("`{text}`: step {i}'s result has no such value (write $$ for a literal $)"))?;
}
Ok(cur.clone())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn references_take_values_from_earlier_results() {
let results = [json!({"index": 0}), json!({"id": 2}), json!({"ids": [5, 6]})];
let p = json!({"id": "$1.id", "ids": ["$2.ids.1", "$last.ids[0]"], "nested": {"all": "$2"}, "n": 3, "flag": true});
assert_eq!(resolve(&p, &results).unwrap(), json!({"id": 2, "ids": [6, 5], "nested": {"all": {"ids": [5, 6]}}, "n": 3, "flag": true}));
}
#[test]
fn plain_strings_and_escapes_are_left_alone() {
let results = [json!({"id": 2})];
let p = json!({"text": "costs $1", "price": "$$1.99", "name": "a$1.id", "dollar": "$", "word": "$lastname"});
assert_eq!(
resolve(&p, &results).unwrap(),
json!({"text": "costs $1", "price": "$1.99", "name": "a$1.id", "dollar": "$", "word": "$lastname"})
);
}
#[test]
fn a_reference_to_nothing_is_an_error() {
let results = [json!({"id": 2})];
for bad in ["$1", "$1.id", "$0.ids", "$0.id.x", "$last.nope", "$7[0]"] {
let e = resolve(&json!({"id": bad}), &results).unwrap_err();
assert!(e.contains(bad), "{bad}: {e}");
}
assert!(resolve(&json!({"id": "$last"}), &[]).is_err(), "no step before the first");
// Huge indexes and deep nesting don't panic.
assert!(resolve(&json!("$99999999999999999999999.id"), &results).is_ok_and(|v| v == json!("$99999999999999999999999.id")));
let mut deep = json!("$0.id");
for _ in 0..200 {
deep = json!([deep]);
}
assert!(resolve(&deep, &results).is_ok());
}
}
+27
View File
@@ -853,3 +853,30 @@ fn new_layers_and_sublayers_are_numbered_together() {
let sub = s.execute("layer.newSublayer", &json!({})).unwrap();
assert_eq!(name(&s, &sub), "Layer 4", "the art doesn't count");
}
/// #785: objects given are used as given (a value that isn't an object id fails, never the
/// selection instead), and `text.setRangeStyle` works on the selected type object without `id`.
#[test]
fn given_targets_are_used_as_given_and_range_style_takes_the_selected_type() {
let mut s = session();
let first = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "first", "size": 30})).unwrap()["id"].clone();
s.execute("text.create", &json!({"x": 10, "y": 140, "text": "second", "size": 30})).unwrap();
for bad in [
json!({"id": "$1.id", "size": 60}),
json!({"id": 2.5, "size": 60}),
json!({"ids": [first.clone(), "$1.id"], "size": 60}),
json!({"ids": 3, "size": 60}),
] {
assert!(s.execute("text.setStyle", &bad).is_err(), "{bad}");
}
// Another kind of id (an effect's) is still not an object id.
let sel = s.doc().unwrap().selection.objects.clone();
assert!(s.execute("effect.apply", &json!({"id": "stylize.dropShadow"})).is_ok());
assert_eq!(s.doc().unwrap().selection.objects, sel);
// The selected type object (the second) is styled when no id is given.
let r = s.execute("text.setRangeStyle", &json!({"start": 0, "end": 3, "style": "Bold"})).unwrap();
assert_ne!(r["id"], first);
s.execute("select.all", &json!({})).unwrap();
assert!(s.execute("text.setRangeStyle", &json!({"start": 0, "end": 3, "size": 40})).is_err(), "two type objects: which one?");
assert!(s.execute("text.setRangeStyle", &json!({"id": "two", "size": 40})).is_err());
}
+32
View File
@@ -150,3 +150,35 @@ fn live_objects_and_single_fills_and_strokes_keep_their_raster_effects() {
assert_eq!(pieces, 2, "item {item}: one piece per fill and stroke");
}
}
#[test]
fn shadows_and_glows_keep_their_blend_modes() {
// #827: a red Screen glow over grey can't darken it, as Normal would.
let glow = Effect {
id: "stylize.outerGlow".into(),
params: json!({"mode": "screen", "color": "#ff0000", "opacity": 100.0, "blur": 20.0}),
visible: true,
};
let mut d = Document::new(200.0, 200.0);
let back = rect(&mut d, Rect::new(0.0, 0.0, 200.0, 200.0), Color::rgb(0.5, 0.5, 0.5));
add(&mut d, back);
let dot = rect(&mut d, Rect::new(70.0, 70.0, 130.0, 130.0), Color::rgb(0.12, 0.12, 0.12));
add(&mut d, with(dot, glow.clone()));
assert_written("screen glow", &d);
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
let back = render(&vectorcraft_pdf::import(&r.bytes).unwrap());
for x in [133, 138, 145] {
let i = (100 * back.width as usize + x) * 4;
let (red, green, blue) = (back.pixels[i], back.pixels[i + 1], back.pixels[i + 2]);
assert!(red > green && green >= 124 && blue >= 124, "x {x}: {:?}", (red, green, blue));
}
// A Multiply shadow and the Screen glow: an image each, composited with its mode.
let mut d = Document::new(200.0, 200.0);
let dot = rect(&mut d, Rect::new(70.0, 70.0, 130.0, 130.0), Color::rgb(0.12, 0.12, 0.12));
add(&mut d, with(with(dot, shadow()), glow));
let flat = crate::flatten_raster_effects(&d).unwrap();
let modes: Vec<_> =
flat.layers[0].children().unwrap()[0].children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.blend).collect();
assert_eq!(modes, [vectorcraft_color::BlendMode::Multiply, vectorcraft_color::BlendMode::Screen]);
}
+18
View File
@@ -129,3 +129,21 @@ fn conventions_resources_and_versioned_results() {
rpc(&mut s, "initialize", json!({"protocolVersion":"2025-06-18"}));
assert!(rpc(&mut s, "tools/list", json!({}))["result"].get("resultType").is_none());
}
/// #785: a batch step can use an earlier step's result (`"$N.path"`); a reference to nothing
/// fails that step.
#[test]
fn batch_steps_refer_to_earlier_results() {
let mut s = Server::new(Box::new(Headless::with_document()));
let steps = json!([
{"id":"text.create","params":{"x":10,"y":60,"text":"plain and bold words","size":30}},
{"id":"shape.rectangle","params":{"x":0,"y":0,"width":20,"height":20}},
{"id":"text.setRangeStyle","params":{"id":"$0.id","start":10,"end":14,"style":"Bold"}},
{"id":"text.setRangeStyle","params":{"id":"$9.id","start":0,"end":1,"style":"Bold"}}
]);
let r = call(&mut s, "command_batch", json!({"steps":steps,"stop_on_error":false}));
let p = payload(&r);
assert_eq!((p["completed"].clone(), p["failed"].clone()), (json!(3), json!(1)), "{p}");
assert_eq!(p["results"][2]["result"]["id"], p["results"][0]["result"]["id"], "the text, not the selected rectangle");
assert!(p["results"][3]["error"].as_str().unwrap().contains("$9.id"), "{p}");
}
+6 -2
View File
@@ -96,7 +96,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"command_batch",
"Run several commands",
"Run steps in order; each edit is its own undo step. Returns completed, failed and results. Stops on the first error by default.",
"Run steps in order; each edit is its own undo step. A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed and results. Stops on the first error by default.",
obj(
json!({"steps": {"type":"array", "items":obj(json!({"id":string("Command id"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}}),
&["steps"],
@@ -899,6 +899,8 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?,
};
let (mut completed, mut failed, mut results) = (0, 0, Vec::new());
// Each step's result (null for a failed one), for later steps' `"$N.path"` references.
let mut values: Vec<Value> = vec![];
for step in steps {
let result = vectorcraft_engine::guard::catch_panic(|| {
let args = step.as_object().ok_or("each step must be an object")?;
@@ -908,7 +910,7 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
let id = req_str(args, "id")?;
let params = match args.get("params") {
None | Some(Value::Null) => json!({}),
Some(v @ Value::Object(_)) => v.clone(),
Some(v @ Value::Object(_)) => vectorcraft_engine::steps::resolve(v, &values)?,
Some(_) => return Err("`params` must be an object".into()),
};
b.call("engine.execute", json!({"command":id,"params":params}))
@@ -917,10 +919,12 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
match result {
Ok(v) => {
completed += 1;
values.push(v.clone());
results.push(json!({"ok":true,"result":v}));
}
Err(e) => {
failed += 1;
values.push(Value::Null);
results.push(json!({"ok":false,"error":e}));
if stop {
break;
+35 -5
View File
@@ -365,11 +365,16 @@ impl Targets {
/// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and
/// unlocked, as the selection tools pick them) pull the pointer into line where they run.
fn for_snap_to_point(self, cx: &ToolContext) -> Self {
let mut t = self.anchors_only();
self.anchors_only().with_rulers(cx)
}
/// These targets and the ruler guides, when they are shown (and so unlocked to the selection
/// tools): they pull a point into line where they run.
fn with_rulers(mut self, cx: &ToolContext) -> Self {
if cx.guides {
t.rulers = cx.doc.guides.iter().map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }).collect();
self.rulers = cx.doc.guides.iter().map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }).collect();
}
t
self
}
/// What a dragged selection's grabbed point snaps to with View → Snap to Point and Smart
@@ -486,8 +491,10 @@ impl Targets {
if d.abs() <= 1e-9 {
continue;
}
// The ruler guides that way count as lines too (#812).
let rulers: Vec<(f64, Point, Kind)> = self.rulers.iter().filter(|r| r.vertical == vertical).filter_map(|r| r.line_at(q, reach)).collect();
// A line that far across the way crosses it at least that far along.
for (t, from, _) in lines_near(lines, v, reach) {
for (t, from, _) in lines_near(lines, v, reach).iter().chain(&rulers) {
let s = (t - a) / d;
// Exactly on the line, whatever the rounding along the way.
let hit = if vertical { Point::new(*t, at.y + dir.y * s) } else { Point::new(at.x + dir.x * s, *t) };
@@ -827,7 +834,9 @@ impl PointSnap {
let targets = if cx.snap_to_pixel || cx.snap_to_grid {
None
} else if cx.smart_guides {
Some((targets().styled(cx), cx.snapping_tolerance))
// Snap to Point keeps pulling points onto the ruler guides with Smart Guides on (#812).
let t = targets().styled(cx);
Some((if cx.snap_to_point { t.with_rulers(cx) } else { t }, cx.snapping_tolerance))
} else if cx.snap_to_point {
Some((targets().for_snap_to_point(cx), cx.snap_tolerance))
} else {
@@ -1397,6 +1406,27 @@ mod tests {
}
}
/// #812: with Smart Guides on, ruler guides pull a drawn point too (Snap to Point), horizontal
/// and vertical alike, also while it slides along a construction guide or Shift's angle.
#[test]
fn drawn_points_snap_to_ruler_guides_with_smart_guides_on() {
let (mut d, _) = doc_with_rect();
d.guides.push(vectorcraft_doc::Guide::new(false, 313.0));
d.guides.push(vectorcraft_doc::Guide::new(true, 331.0));
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut snap = DrawSnap::default();
assert_eq!(snap.press(&c, Point::new(260.0, 315.0), &[], None), Point::new(260.0, 313.0), "the horizontal guide");
assert_eq!(snap.press(&c, Point::new(329.0, 260.0), &[], None), Point::new(331.0, 260.0), "the vertical guide");
// On the 45° construction guide from (210, 210), where the horizontal guide crosses it.
let from = Leave::segment(&c, Point::new(210.0, 210.0), false);
let q = snap.press(&c, Point::new(312.0, 311.0), &[], Some(&from));
assert!((q - Point::new(313.0, 313.0)).hypot() < 1e-9, "{q:?}");
// Snap to Point off: Smart Guides alone don't use the ruler guides.
let off = ToolContext { snap_to_point: false, ..cx(&d, &s, &p) };
assert_eq!(snap.press(&off, Point::new(260.0, 315.0), &[], None), Point::new(260.0, 315.0));
}
/// A drawing tool's snapper reuses its targets while hovering over the same document state, and
/// gathers them again when it changes, and on every press.
#[test]
+73 -1
View File
@@ -71,6 +71,8 @@ pub struct SelectionTool {
targets: Option<Targets>,
moving: Option<MoveSnap>,
guide: GuideEdit,
/// Smart Guides: the centre or anchor under the pointer, and its label ([`Self::hover_point`]).
hover: Option<(Point, &'static str)>,
}
/// Snapping for the selection moved as a whole, its targets gathered when the move begins: View →
@@ -163,10 +165,36 @@ fn box_hit(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option<BoxHit> {
in_rotate_zone(b.rect, lp, tol, cx.tol(18.0)).map(|_| BoxHit::Rotate)
}
/// How near (screen points) the pointer must come to an object's centre or anchor for Smart Guides
/// to label it while hovering.
const HOVER_REACH: f64 = 6.0;
impl SelectionTool {
fn drag_threshold(cx: &ToolContext) -> f64 {
cx.tol(3.0)
}
/// With Smart Guides on, the anchor or the centre of an object near `p` (the one under the
/// pointer, else a selected one) and its label, "anchor" or "center", as Illustrator shows
/// them while hovering (#812). A press there grabs the object by that point.
fn hover_point(cx: &ToolContext, p: Point) -> Option<(Point, &'static str)> {
if !cx.smart_guides {
return None;
}
let reach = cx.tol(HOVER_REACH);
let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation));
let nearest = |points: &mut dyn Iterator<Item = Point>| {
points.filter(|q| q.distance(p) <= reach).min_by(|a, b| a.distance(p).total_cmp(&b.distance(p)))
};
under.into_iter().chain(cx.selection.objects.iter().copied()).find_map(|id| {
let n = cx.doc.node(id)?;
let anchor = n.path_data().and_then(|pd| nearest(&mut pd.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))));
anchor.map(|q| (q, "anchor")).or_else(|| {
let c = cx.doc.bounds_of(&[id], cx.preview_bounds)?.center();
(c.distance(p) <= reach).then_some((c, "center"))
})
})
}
}
impl Tool for SelectionTool {
@@ -188,6 +216,11 @@ impl Tool for SelectionTool {
}
let p = ev.pos;
let m = ev.mods;
// A press near the hovered centre or anchor grabs the object by it.
let grab = self.hover.take().map(|(q, _)| q).filter(|q| q.distance(p) <= cx.tol(HOVER_REACH)).unwrap_or(p);
if matches!(ev.kind, PointerKind::Move) && matches!(self.state, State::Idle) {
self.hover = Self::hover_point(cx, p);
}
match (ev.kind, self.state.clone()) {
(PointerKind::DoubleClick, _) => {
self.state = State::Idle;
@@ -278,7 +311,7 @@ impl Tool for SelectionTool {
} else if cx.selection.objects.len() > 1 && !m.cmd && !m.alt {
key = Some(top);
}
self.state = State::Moving { start: p, began: false, deselect, key };
self.state = State::Moving { start: grab, began: false, deselect, key };
out
}
None => {
@@ -397,6 +430,12 @@ impl Tool for SelectionTool {
}
o.extend(self.guides.iter().cloned());
o.extend(self.guide.overlays(cx));
if let (Some((q, label)), State::Idle) = (self.hover, &self.state) {
let style = crate::guides::GuideStyle::of(cx);
if style.labels {
o.push(Overlay::Label { p: q, text: label.into(), color: style.color });
}
}
if let Some((p, t)) = &self.measure {
o.push(Overlay::Measure { p: *p, text: t.clone() });
}
@@ -483,6 +522,39 @@ mod tests {
PointerEvent::new(kind, x, y)
}
/// #812: with Smart Guides on, hovering near an object's centre or an anchor labels it, and a
/// press there grabs the object by that point.
#[test]
fn hovering_labels_the_centre_and_anchors_and_a_press_grabs_by_them() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let label = |t: &SelectionTool| {
t.overlays(&cx).iter().find_map(|o| match o {
Overlay::Label { p, text, .. } => Some((*p, text.clone())),
_ => None,
})
};
let mut t = SelectionTool::default();
t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0));
assert_eq!(label(&t), Some((Point::new(150.0, 150.0), "center".to_string())));
t.pointer(&cx, &ev(PointerKind::Move, 102.0, 101.0));
assert_eq!(label(&t), Some((Point::new(100.0, 100.0), "anchor".to_string())));
t.pointer(&cx, &ev(PointerKind::Move, 130.0, 170.0));
assert_eq!(label(&t), None, "away from both");
// Grabbed by its centre, the object's centre follows the pointer.
t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0));
t.pointer(&cx, &ev(PointerKind::Down, 152.0, 151.0));
let a = t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 250.0));
assert!(a.iter().any(|a| matches!(a, Action::Preview(_, v) if v["matrix"][4] == 100.0 && v["matrix"][5] == 100.0)), "{a:?}");
// With Smart Guides off, nothing is labelled.
let off = ToolContext { smart_guides: false, ..cx };
let mut t = SelectionTool::default();
t.pointer(&off, &ev(PointerKind::Move, 152.0, 151.0));
assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Label { .. })));
}
#[test]
fn click_selects_object() {
let (d, id) = doc_with_rect();
+5
View File
@@ -3782,3 +3782,8 @@ axis Both Ambos
Constrain Restringir
Free Transform Transformación libre
Perspective Distort Distorsión de perspectiva
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` es la referencia a un paso {r}, que solo `run` y `command_batch` resuelven
@msg `ids` must be an array of object ids, not {v} `ids` debe ser una lista de identificadores de objeto, no {v}
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: el resultado del paso {i} no tiene ese valor (escriba $$ para un $ literal)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: no hay ningún paso {i} antes de este (los pasos se cuentan desde 0; escriba $$ para un $ literal)
@msg object ids are non-negative integers, not {v} los identificadores de objeto son enteros no negativos, no {v}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -3793,3 +3793,8 @@ axis Both Les deux
Insert Inline Symbol Insérer un symbole dans le texte
ICF Top/Right ICF haut/droite
ICF Bottom/Left ICF bas/gauche
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` est la référence d’étape {r}, que seuls `run` et `command_batch` résolvent
@msg `ids` must be an array of object ids, not {v} `ids` doit être une liste d’identifiants d’objet, pas {v}
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}` : le résultat de l’étape {i} n’a pas cette valeur (écrivez $$ pour un $ littéral)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}` : il n’y a pas d’étape {i} avant celle-ci (les étapes sont comptées à partir de 0 ; écrivez $$ pour un $ littéral)
@msg object ids are non-negative integers, not {v} les identifiants d’objet sont des entiers positifs ou nuls, pas {v}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -3782,3 +3782,8 @@ axis Both Entrambi
Constrain Vincola
Free Transform Trasformazione libera
Perspective Distort Distorsione prospettica
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` è il riferimento al passaggio {r}, che solo `run` e `command_batch` risolvono
@msg `ids` must be an array of object ids, not {v} `ids` deve essere un elenco di ID oggetto, non {v}
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: il risultato del passaggio {i} non ha questo valore (scrivi $$ per un $ letterale)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: non c’è un passaggio {i} prima di questo (i passaggi si contano da 0; scrivi $$ per un $ letterale)
@msg object ids are non-negative integers, not {v} gli ID oggetto sono interi non negativi, non {v}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -3774,3 +3774,8 @@ theme Appearance 外観
@msg larger than 256 MB 256 MB を超えています
@msg the files come to more than 1 GB: add fewer at a time ファイルの合計が 1 GB を超えています: 一度に追加するファイルを減らしてください
@msg every numbered name for it is taken in the Fonts folder Fonts フォルダーでは、このファイルの番号付きの名前がすべて使われています
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです
@msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません)
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ステップ {i} の結果にその値はありません ($ そのものを書くには $$ と入力してください)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: この前にステップ {i} はありません (ステップは 0 から数えます。$ そのものを書くには $$ と入力してください)
@msg object ids are non-negative integers, not {v} オブジェクト ID は 0 以上の整数です ({v} は使えません)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -3780,3 +3780,8 @@ axis Both Оба
Perspective Distort Искажение перспективы
Artboard Монтажная область
Exit Выйти
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — это ссылка на шаг {r}, которую разрешают только `run` и `command_batch`
@msg `ids` must be an array of object ids, not {v} `ids` должен быть массивом идентификаторов объектов, а не {v}
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: в результате шага {i} нет такого значения (чтобы написать сам $, введите $$)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед этим шагом нет шага {i} (шаги считаются с 0; чтобы написать сам $, введите $$)
@msg object ids are non-negative integers, not {v} идентификаторы объектов — неотрицательные целые числа, а не {v}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -3765,3 +3765,8 @@ axis Both Обидві
Perspective Distort Спотворення перспективи
Artboard Монтажна область
Exit Вийти
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — це посилання на крок {r}, яке розв’язують лише `run` і `command_batch`
@msg `ids` must be an array of object ids, not {v} `ids` має бути масивом ідентифікаторів об’єктів, а не {v}
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: у результаті кроку {i} немає такого значення (щоб написати сам $, введіть $$)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед цим кроком немає кроку {i} (кроки рахуються з 0; щоб написати сам $, введіть $$)
@msg object ids are non-negative integers, not {v} ідентифікатори об’єктів — невід’ємні цілі числа, а не {v}
Can't render this file because it contains an unexpected character in line 632 and column 40.
+1 -1
View File
@@ -377,7 +377,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"window.swatchLibrary.other",
"Other Library…",
"",
"{path?} (default: pick a file) load a .vcswatches, .gpl or .ase library, or another document's swatches (engine: swatch.library.load), and open it in the library panel",
"{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in the library panel",
),
(
"ui.saveSwatchLibrary",
+11 -1
View File
@@ -167,7 +167,7 @@ The older names below remain listed because existing workflows use them.
|---|---|
| `command_list` | Optional `filter`, `enabled_only`; returns the command array |
| `command_run` | `id`, optional `params`; runs the command through the existing backend |
| `command_batch` | `steps: [{id, params?}]`, optional `stop_on_error` (default true); returns `completed`, `failed`, `results: [{ok, result\|error}]`. Each edit has its own undo step; failures set `isError` |
| `command_batch` | `steps: [{id, params?}]`, optional `stop_on_error` (default true); returns `completed`, `failed`, `results: [{ok, result\|error}]`. Each edit has its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below) |
| `doc_inspect` | Optional `depth`, `childLimit`; same summary as `inspect_document` |
| `render_preview` | Optional `max_side` (1–4096, default 1024); inline PNG of the first artboard without editing it. Artboards too large to render within the allocation bound return a tool error |
| `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode |
@@ -573,6 +573,16 @@ applies to the `--cmd` just before it. `run` also accepts the host commands `fil
`file.exportForScreens` and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads
(`vectorcraft-cli --help` lists them).
A step can use an earlier step's result, in `run` as in the `command_batch` tool. A parameter string that is exactly
`$N` or `$N.path` takes that value from the result of step `N` (counting from 0, in the order they ran; `run --in`'s
open isn't a step), and `$last…` from the step before. A path goes into objects by key and arrays by index:
`"$1.id"`, `"$2.ids.0"`, `"$last.ids[0]"`. A string starting with `$$` stands for one `$` (`"$$1.99"` is `$1.99`). A
reference that names no value fails the step, so a command never runs with one left in it:
```sh
vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd text.create --params '{"x":10,"y":60,"text":"plain and bold words","size":30}' --cmd text.setRangeStyle --params '{"id":"$1.id","start":10,"end":14,"style":"Bold"}'
```
## Transparency and opacity masks
Transparency and opacity-mask commands take `ids` (or `id`), so they need no selection. Opacity is a percentage