From 199a46414012f36bb490982246bf9ab8118d59a4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 23:25:47 +0700 Subject: [PATCH 01/97] #964: start again when the first frame never reaches the screen With High Performance chosen on a hybrid laptop, the window's first frame never reached the screen: no error, no panic, the app stayed in the background without a window and the log stopped after "rendering with". The start-up recovery of #502 only saw failures. A watch thread now gives the first frame 20 seconds from the app's creation (after the files named at the start opened). When the second frame hasn't begun by then, so the first was never presented, the app starts again without that adapter, as it does after a graphics failure, and this process ends. A window minimized at the start isn't counted. When the hung processor was chosen in Preferences, the status bar's note after the restart points to Automatic. docs/development.md explains it and how to recover from a Graphics Processor choice that doesn't work; ROADMAP Last updated. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- apps/vectorcraft/src/gpu.rs | 97 ++++++++++++++++++++++++++++++++++++ apps/vectorcraft/src/main.rs | 19 ++++++- docs/development.md | 2 + 4 files changed, 117 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5bc16cbae..282d06ccd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2), Variables (data merge): text and visibility variables, data sets and the Variables panel (#761); see [Honest assessment](#honest-assessment-2026-10-05))._ +_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 window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2), Variables (data merge): text and visibility variables, data sets and the Variables panel (#761), a window whose first frame never reaches the screen starting again on the next graphics processor (#964); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index 264583ed5..2442a4e98 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -26,9 +26,16 @@ //! the graphics fail while the window is starting up, [`finish`] starts the app again without //! that adapter, as the next one in the order would be tried. Each restart leaves out one more //! adapter, so it ends when none is left. +//! +//! An adapter can also hang instead of failing: on a hybrid laptop with High Performance chosen, +//! the discrete GPU never put the window's first frame on the screen, with no error and no panic, +//! and the app stayed in the background without a window (#964). [`watch_first_frame`] gives the +//! first frame [`FIRST_FRAME_TIMEOUT`] once the app is created and, when it doesn't come, starts +//! the app again without that adapter in the same way. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; use eframe::egui_wgpu::NativeAdapterSelectorMethod; use eframe::wgpu::{self, Backend, Backends, DeviceType, PowerPreference}; @@ -50,6 +57,11 @@ const HELP: &str = /// window is something else (such as the compositor restarting). pub const STARTUP_FRAMES: u64 = 10; +/// How long the window's first frame may take to reach the screen, from the app's creation, +/// before its adapter counts as hanging ([`watch_first_frame`]). The first frame draws the whole +/// interface and compiles its shaders, a second at most on a slow machine. +pub const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(20); + /// The preference used when Preferences say Automatic. Windows and macOS show frames from any GPU /// (the integrated one avoids the flicker of #306). Elsewhere, the system's own order: Mesa's /// device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid @@ -311,6 +323,8 @@ pub struct Startup { adapter: OnceLock, /// The UI's context, which counts the frames run. ui: OnceLock, + /// When the app was created, the files named at the start opened: the first frame is due. + ready: OnceLock, } impl Startup { @@ -319,6 +333,71 @@ impl Startup { // Set once; the window is created once. let _ = self.ui.set(ctx.clone()); } + + /// The app is ready to draw its first frame. + pub fn ready(&self) { + // Set once, like `created`. + let _ = self.ready.set(Instant::now()); + } +} + +/// What [`watch_first_frame`] makes of the window's start. +#[derive(Debug, PartialEq, Eq)] +enum FirstFrame { + /// Not due yet. + Wait, + /// On the screen (the second frame began, so the first was presented), or the window was + /// minimized at the start: nothing to watch. + Shown, + /// Overdue. + Hung, +} + +/// The window's start after `frames` frames run, `since` its first frame was due. +fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame { + if frames >= 2 || minimized { + FirstFrame::Shown + } else if since >= FIRST_FRAME_TIMEOUT { + FirstFrame::Hung + } else { + FirstFrame::Wait + } +} + +/// Watch the window's first frame from another thread: when it hasn't reached the screen +/// [`FIRST_FRAME_TIMEOUT`] after the app was created ([`Startup::ready`]), start the app again +/// without the adapter, as [`finish`] does for one that failed, and end this process, whose UI +/// thread is stuck in the graphics driver. +pub fn watch_first_frame(startup: Arc) { + let watch = move || loop { + std::thread::sleep(Duration::from_millis(250)); + let Some(due) = startup.ready.get() else { continue }; + let ui = startup.ui.get(); + let frames = ui.map_or(0, egui::Context::cumulative_frame_nr); + let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false); + match first_frame(frames, due.elapsed(), minimized) { + FirstFrame::Wait => continue, + FirstFrame::Shown => return, + FirstFrame::Hung => {} + } + let mut skip = skipped(); + let Some(failed) = restart_without(true, frames, startup.adapter.get().map(String::as_str), &skip) else { return }; + log::error!( + "the window's first frame didn't reach the screen in {}s on graphics adapter {failed}: starting again without it", + FIRST_FRAME_TIMEOUT.as_secs() + ); + skip.push(failed.to_string()); + match restart(&skip.join(",")) { + Ok(()) => std::process::exit(0), + Err(e) => { + log::error!("starting again failed: {e}"); + return; + } + } + }; + if let Err(e) = std::thread::Builder::new().name("first frame".into()).spawn(watch) { + log::warn!("no watch on the window's first frame: {e}"); + } } /// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPUs that drive a @@ -729,6 +808,24 @@ mod tests { assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); } + #[test] + fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() { + let s = Duration::from_secs; + // The second frame began: the first was presented. + assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown); + // Still drawing (or presenting) the first frame: waited for, then given up on (#964). + for frames in [0, 1] { + assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait); + assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung); + } + // A window minimized at the start may not draw again until restored. + assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown); + // A hung adapter is left out once, like one that failed. + let skip = vec!["Dx12:10de:28a0".to_string()]; + assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); + assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8")); + } + #[test] fn a_restart_tries_the_next_adapter() { let c = issue_502(); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 4589a2e29..277788c14 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -504,7 +504,9 @@ fn main() -> std::process::ExitCode { #[cfg(feature = "wgpu")] let displays = gpu::preferred_displays(gpu_pref, power_env); #[cfg(feature = "wgpu")] - if gpu::automatic(gpu_pref, power_env) { + let chosen_gpu = !gpu::automatic(gpu_pref, power_env); + #[cfg(feature = "wgpu")] + if !chosen_gpu { let listed: Vec = displays.iter().map(ToString::to_string).collect(); log::info!("display GPUs (PCI vendor:device): {}", if listed.is_empty() { "unknown".to_string() } else { listed.join(", ") }); } else { @@ -534,6 +536,8 @@ fn main() -> std::process::ExitCode { }; #[cfg(feature = "wgpu")] gpu::watch_panics(); + #[cfg(feature = "wgpu")] + gpu::watch_first_frame(startup.clone()); // Files opened from Finder and the Dock arrive as events, not arguments. #[cfg(target_os = "macos")] open_documents::install(); @@ -577,7 +581,15 @@ fn main() -> std::process::ExitCode { log::info!("rendering with {summary} (power preference {power:?})"); created.created(&cc.egui_ctx); if !gpu::skipped().is_empty() { - app.status(format!("The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}")); + // A processor chosen in Settings can be the one that can't show the window. + let hint = if chosen_gpu { + " (Settings › Performance › Graphics Processor › Automatic uses the one that drives your display)" + } else { + "" + }; + app.status(format!( + "The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}{hint}" + )); } app.graphics_adapter = Some(summary); let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone()); @@ -609,6 +621,9 @@ fn main() -> std::process::ExitCode { #[cfg(not(target_os = "macos"))] let _ = in_window_menus; file_access::unconfined(|| open_files(&mut app, files)); + // The first frame is due from now (`gpu::watch_first_frame`). + #[cfg(feature = "wgpu")] + created.ready(); Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 })) }), ) diff --git a/docs/development.md b/docs/development.md index 651284e5b..9fd227210 100644 --- a/docs/development.md +++ b/docs/development.md @@ -57,6 +57,8 @@ The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU ( - **Automatic on Linux and the BSDs** is the GPU that drives the panel or a monitor, then the system's order: Mesa's Vulkan device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid laptops; `DRI_PRIME` and `MESA_VK_DEVICE_SELECT` steer it). A Wayland compositor may not accept frames from another GPU: on a desktop whose compositor ran on an NVIDIA GPU, rendering on the Ryzen's integrated GPU made KWin end the window's connection ("importing the supplied dmabufs failed") and 0.5.0 crashed at startup (#502). - 0.5.0 saved `powerSaving`, its default, for everyone; it reads as `automatic`. - **If the window fails on its adapter while starting up** (an error from wgpu, or a panic inside wgpu or egui-wgpu during the first frames), the app starts again without that adapter and tries the next one, telling which in the status bar; the run that failed keeps its log as `vectorcraft.1.log`. On Unix the new app replaces the process (an AppImage stays mounted); on Windows it starts beside it. Adapters are told apart by backend and PCI ids, or by name where the backend reports no ids (Metal lists every GPU of a dual-GPU Mac as `0000:0000`, #651). The adapters left out travel in `VECTORCRAFT_GPU_SKIP` (`backend:vendor:device`, such as `Vulkan:1002:164e`), one more on each restart, so the restarts end. When no adapter is left, the app logs why and exits with an error instead of panicking. +- **If the window's first frame never reaches the screen** within 20 seconds of the app being created (the files named at the start opened), the adapter counts as hanging and the app starts again without it in the same way (#964: High Performance on a hybrid laptop left the app running in the background with no window and nothing in the log). A window minimized at the start isn't counted. When the processor that hung was chosen in Preferences, the status bar points to Automatic. +- **To recover from a Graphics Processor choice that doesn't work** without editing files: start once with `WGPU_POWER_PREF=low` (or `WGPU_ADAPTER_NAME=…`), which overrides the preference for that start while settings still save, and set Graphics Processor back to Automatic. `VECTORCRAFT_NO_PREFS=1` also starts without it, but saves nothing. - **Environment variables** for when the choice still goes wrong, all read at startup and stronger than the preference: - `WGPU_POWER_PREF`: `low`, `high` or `none` (the system's order). - `WGPU_ADAPTER_NAME`: part of an adapter's name, any case (`WGPU_ADAPTER_NAME=nvidia`, `=radv`, `=intel`); the names are in the log. From c36b6976e3277af07a5c215b0be34d97ca9258c1 Mon Sep 17 00:00:00 2001 From: alpha-rock <44344200+alpha-rock@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:34:34 +0400 Subject: [PATCH 02/97] M9: add live Revolve with artboard rotation and lighting controls --- Cargo.lock | 9 + Cargo.toml | 1 + README.md | 3 +- ROADMAP.md | 15 +- crates/effects/Cargo.toml | 1 + crates/effects/src/bake.rs | 14 +- crates/effects/src/lib.rs | 11 + crates/effects/src/revolve.rs | 319 +++++++++++++ crates/engine/src/cmd/effectcmd.rs | 99 ++++ crates/render/src/fx.rs | 10 +- crates/render/src/tests_objectfx.rs | 63 +++ crates/svg/tests/effects_export.rs | 17 + crates/three-d/Cargo.toml | 12 + crates/three-d/src/lib.rs | 419 +++++++++++++++++ crates/three-d/src/visibility.rs | 325 ++++++++++++++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/canvas.rs | 12 +- crates/ui-egui/src/chrome.rs | 12 +- crates/ui-egui/src/dialogs/effect.rs | 6 + crates/ui-egui/src/dialogs/mod.rs | 15 +- crates/ui-egui/src/dialogs/modal.rs | 11 +- crates/ui-egui/src/dialogs/revolve.rs | 233 ++++++++++ crates/ui-egui/src/dialogs/revolve_gizmo.rs | 425 ++++++++++++++++++ crates/ui-egui/src/dialogs/tests.rs | 20 + crates/ui-egui/src/dialogs/tests_revolve.rs | 201 +++++++++ .../src/dialogs/tests_revolve_gizmo.rs | 250 +++++++++++ crates/ui-egui/src/i18n/cs.tsv | 29 ++ crates/ui-egui/src/i18n/es.tsv | 26 ++ crates/ui-egui/src/i18n/fr.tsv | 24 + crates/ui-egui/src/i18n/it.tsv | 26 ++ crates/ui-egui/src/i18n/ja.tsv | 26 ++ crates/ui-egui/src/i18n/pt-br.tsv | 27 ++ crates/ui-egui/src/i18n/ru.tsv | 26 ++ crates/ui-egui/src/i18n/uk.tsv | 24 + crates/ui-egui/src/i18n/zh-hans.tsv | 26 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 26 ++ crates/ui-egui/src/menus.rs | 4 + crates/ui-egui/src/shortcuts.rs | 6 +- crates/ui-egui/src/theme.rs | 7 + docs/mcp.md | 28 ++ docs/revolve.md | 79 ++++ xtask/src/layers.rs | 2 + 42 files changed, 2867 insertions(+), 23 deletions(-) create mode 100644 crates/effects/src/revolve.rs create mode 100644 crates/three-d/Cargo.toml create mode 100644 crates/three-d/src/lib.rs create mode 100644 crates/three-d/src/visibility.rs create mode 100644 crates/ui-egui/src/dialogs/revolve.rs create mode 100644 crates/ui-egui/src/dialogs/revolve_gizmo.rs create mode 100644 crates/ui-egui/src/dialogs/tests_revolve.rs create mode 100644 crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs create mode 100644 docs/revolve.md diff --git a/Cargo.lock b/Cargo.lock index f70399d2c..70910ff1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4449,6 +4449,7 @@ dependencies = [ "vectorcraft-pathops", "vectorcraft-plugins", "vectorcraft-text", + "vectorcraft-three-d", ] [[package]] @@ -4704,6 +4705,13 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "vectorcraft-three-d" +version = "0.8.0" +dependencies = [ + "vectorcraft-geom", +] + [[package]] name = "vectorcraft-tools" version = "0.8.0" @@ -4758,6 +4766,7 @@ dependencies = [ "vectorcraft-svg", "vectorcraft-testkit", "vectorcraft-text", + "vectorcraft-three-d", "vectorcraft-tools", "wat", ] diff --git a/Cargo.toml b/Cargo.toml index 3e9551a72..975b0e8ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ vectorcraft-brush = { path = "crates/brush" } vectorcraft-trace = { path = "crates/trace" } vectorcraft-text = { path = "crates/text" } vectorcraft-effects = { path = "crates/effects" } +vectorcraft-three-d = { path = "crates/three-d" } vectorcraft-render = { path = "crates/render" } vectorcraft-svg = { path = "crates/svg" } vectorcraft-pdf = { path = "crates/pdf" } diff --git a/README.md b/README.md index 79b26dbbc..825df6619 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ The scores are self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them. **What's missing:** + - 3D and Materials; - the Photoshop-style raster effects (Effect Gallery); - CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface); @@ -229,7 +230,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0 advanced type, then hardening and packaging for 1.0. The prioritized list is in [Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order). -**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}` +**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}` and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so the UI can be swapped without touching the engine. diff --git a/ROADMAP.md b/ROADMAP.md index 282d06ccd..19ba2188a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2), Variables (data merge): text and visibility variables, data sets and the Variables panel (#761), a window whose first frame never reaches the screen starting again on the next graphics processor (#964); see [Honest assessment](#honest-assessment-2026-10-05))._ +_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 window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2), Variables (data merge): text and visibility variables, data sets and the Variables panel (#761), a window whose first frame never reaches the screen starting again on the next graphics processor (#964), Effect › 3D and Materials › Revolve as a live effect with rotation and light gizmos (#846, part of gap 3); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -35,7 +35,7 @@ since 2026-10-01. |---|---|---| | **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | | **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) | -| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting | +| **Large missing subsystems** | 0–20% | 3D & Materials (~8%, initial Revolve only), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting | | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | @@ -63,9 +63,9 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent Brick, Burlap, Canvas and Sandstone surfaces made in code; Load Texture is missing) and Video › De-Interlace and NTSC Colors; each new filter is now one function, a catalogue row, menu and translations. Parallelizes well across agents. 19–31 h. -3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software - renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors. - The largest single gap. 50–80 h. +3. **3D and Materials:** initial Revolve has an independent software geometry crate, editable profiles, + shaded vector output and basic lighting. Extrude & Bevel, Inflate, Rotate, materials, caps and more complete + visibility remain. The subsystem is still an early implementation. 45–75 h. 4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, proportional vertical metrics, manual tate-chu-yoko), Optical Margin Alignment, the hyphenation and justification options (the Every-line/Single-line composer choice is done), tab leaders, a @@ -155,6 +155,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). - **Geometry and effects:** + - Initial live Revolve (#605): a portable geometry and shading crate, editable path/compound profiles, compact options with artboard rotation and light gizmos, numeric scrubbing/sliders/reset, command access, SVG/PDF vector output and Expand Appearance. A Revolve-specific expansion option trims covered opaque solid surfaces while preserving visible interiors. Open ends are uncapped; shading is flat and intersecting-surface visibility approximate. - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). - **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). @@ -200,7 +201,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | -| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | +| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; graph Design/Column/Marker designs pending | 6–10 | @@ -232,7 +233,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 | -| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | +| 3D and Materials | 4 | ~8% | Initial Revolve only, with simple lighting and Revolve-specific visible-surface expansion; Extrude & Bevel, Inflate, Rotate, caps, materials, shadows and more complete visibility remain | 45–75 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index aad25ac93..49b8e297f 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] vectorcraft-geom = { workspace = true } +vectorcraft-three-d = { workspace = true } vectorcraft-doc = { workspace = true } vectorcraft-pathops = { workspace = true } vectorcraft-text = { workspace = true } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index f1a08bee9..148f2c018 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -39,7 +39,8 @@ fn clear_item_effects(item: &mut AppearanceItem) { /// Does anything in `n`'s subtree need baking? pub fn needs_bake(n: &Node) -> bool { - has_adjustment(n) + crate::has_revolve(n) + || has_adjustment(n) || has_crop_marks(n) || has_pathfinder(n) || has_own_paint(n) @@ -92,6 +93,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if let Some(m) = crop_marks_art(n) { return Some(bake_pieces(d, m)); } + if let Some(m) = crate::revolve_art(n) { + let m = evaluate_container(&m).unwrap_or(m); + return Some(bake_pieces(d, m)); + } // Type, images, symbol instances and live objects: reshaped through their outlines. if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) { let symbol = match &n.kind { @@ -226,6 +231,10 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, & /// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and /// effects are dropped. `None` for other kinds of objects. pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option { + if let Some(m) = crate::revolve::expand_revolve_art(n) { + let m = evaluate_container(&m).unwrap_or(m); + return Some(bake_pieces(d, m)); + } let (g, rule, ctx, mut m) = leaf_base(n)?; m.appearance.effects.retain(|e| e.visible); let mut pieces = vec![]; @@ -289,6 +298,9 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { /// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None` /// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing. pub fn bake_appearance(n: &Node) -> Option { + if let Some(m) = crate::revolve_art(n) { + return Some(m); + } if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) { return None; } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 1a32b427b..1814ef08c 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -42,6 +42,7 @@ mod marks; pub mod pixel; mod raster; mod reshape; +mod revolve; pub mod stroke; mod stylize; mod util; @@ -68,6 +69,7 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RasterFx, outset, raster_effects}; pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape}; +pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use warp::{WarpStyle, warp_point}; /// Catalogue entry for one effect. @@ -122,6 +124,7 @@ fn lengths_of(id: &str) -> Lengths { "distort.tweak" => Lengths { always: &[], absolute: &["h", "v"] }, "distort.transform" => always(&["moveH", "moveV"]), "path.offsetPath" => always(&["offset"]), + "threeD.revolve" => always(&["offset"]), "path.outlineStroke" => always(&["width"]), "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), "stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]), @@ -179,6 +182,13 @@ pub fn effect_catalog() -> Vec { let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false, lengths: lengths_of(id) }; let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true, lengths: lengths_of(id) }; let mut v = vec![ + g( + REVOLVE, + "Revolve…", + &["Effect", "3D and Materials"], + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}), + ), g( "convertToShape.rectangle", "Rectangle…", @@ -589,6 +599,7 @@ pub fn is_geometry(id: &str) -> bool { && !is_pathfinder(id) && !is_adjustment(id) && id != CROP_MARKS + && id != REVOLVE && (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some()) } diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs new file mode 100644 index 000000000..2d5fa3eb8 --- /dev/null +++ b/crates/effects/src/revolve.rs @@ -0,0 +1,319 @@ +//! Revolve as live, shaded vector art, shared by the canvas and every exporter. +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer}; +use vectorcraft_geom::{PathData, Rect}; +use vectorcraft_three_d::{Edge, Revolve}; + +use crate::util::{flag, num, text}; +use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; + +pub const REVOLVE: &str = "threeD.revolve"; + +fn visible(fx: &[Effect]) -> bool { + fx.iter().any(|e| e.visible && e.id == REVOLVE) +} + +pub fn has_revolve(n: &Node) -> bool { + visible(&n.appearance.effects) || n.appearance.items.iter().any(|i| i.visible() && visible(i.effects())) +} + +/// Shared document-parameter conversion for rendering and interactive preview guides. +pub fn revolve_options(p: &Value) -> Revolve { + Revolve { + angle: num(p, "angle", 360.0), + offset: num(p, "offset", 0.0), + edge: if text(p, "edge", "left") == "right" { Edge::Right } else { Edge::Left }, + rotation_x: num(p, "rotationX", 0.0), + rotation_y: num(p, "rotationY", 0.0), + rotation_z: num(p, "rotationZ", 0.0), + perspective: num(p, "perspective", 0.0) / 100.0, + segments: num(p, "segments", 64.0).clamp(8.0, 128.0) as usize, + light_azimuth: num(p, "lightAzimuth", -45.0), + light_elevation: num(p, "lightElevation", 45.0), + light_intensity: num(p, "lightIntensity", 80.0) / 100.0, + ambient: num(p, "ambient", 25.0) / 100.0, + shade: flag(p, "shade", true), + } +} + +/// Validate before a command mutates the document. Loading external files is still bounded +/// by the evaluator; unsupported source kinds remain unchanged when rendered. +pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { + let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?; + let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; + if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) { + return Err("Revolve requires at least two anchors".into()); + } + if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) { + return Err("Revolve edge must be left or right".into()); + } + vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) +} + +fn profile(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, guide: false, clipping: false, .. } => Some(path.clone()), + NodeKind::Compound { children, .. } => { + Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect())) + } + _ => None, + } +} + +fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { + apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx) +} + +#[allow(clippy::too_many_arguments)] +fn surface( + n: &Node, + path: &PathData, + fx: &Effect, + paint: Paint, + opacity: f32, + blend: vectorcraft_doc::color::BlendMode, + expand: bool, +) -> Option { + let b = path.bounds()?; + // A loaded file or a profile edited past the limits keeps drawing its source path instead + // of crashing the frame. Commands validate the same evaluator and report its errors. + let faces = vectorcraft_three_d::revolve(path, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?; + // Visibility is an expansion option, never a change to live geometry or export baking. + // Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility + // failure also retains the complete surface rather than losing any of the user's art. + let visible = (expand + && flag(&fx.params, "expandVisibleOnly", true) + && paint.color().is_some() + && opacity == 1.0 + && blend == Default::default() + && n.has_default_transparency()) + .then(|| vectorcraft_three_d::visible_faces(&faces).ok()) + .flatten(); + let faces = visible.unwrap_or_else(|| { + faces.into_iter().map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth }).collect() + }); + let children = faces + .into_iter() + .map(|face| { + let mut fill = FillLayer::new(paint.clone()); + let mut items = vec![]; + if let Some(c) = paint.color() { + let [r, g, b] = c.to_rgb(); + let s = face.brightness as f32; + fill.paint = Paint::solid(Color::rgb(r * s, g * s, b * s)); + items.push(AppearanceItem::Fill(fill)); + } else { + items.push(AppearanceItem::Fill(fill)); + let mut shadow = FillLayer::new(Paint::solid(Color::BLACK)); + shadow.opacity = (1.0 - face.brightness) as f32; + items.push(AppearanceItem::Fill(shadow)); + } + // Independently antialiased adjacent polygons otherwise leave a hairline grid. + // Matching narrow edge paint overlaps their coverage in the canvas and exporters. + // Keep opacity on the surface group so these edges never accumulate transparency. + let items = items + .into_iter() + .flat_map(|item| { + let AppearanceItem::Fill(fill) = &item else { return vec![item] }; + let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75); + edge.opacity = fill.opacity; + vec![item, AppearanceItem::Stroke(edge)] + }) + .collect(); + Arc::new(Node::path(n.id, face.path, Appearance { items, ..Default::default() })) + }) + .collect(); + let mut group = Node::group(n.id, children); + group.opacity = opacity; + group.blend = blend; + Some(group) +} + +fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) { + match item { + AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend), + AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend), + } +} + +/// Evaluated art keeps the source's identity and transparency. Each item's opacity belongs +/// to its entire surface, not to each face, so overlapping faces don't accumulate alpha. +pub fn revolve_art(n: &Node) -> Option { + art(n, false) +} + +/// Revolve's own Expand Appearance path. The shared command and UI remain unchanged. +pub(crate) fn expand_revolve_art(n: &Node) -> Option { + art(n, true) +} + +fn art(n: &Node, expand: bool) -> Option { + if !has_revolve(n) { + return None; + } + let path = profile(n)?; + let ctx = GeomContext::of(n); + let mut children = vec![]; + let mut remaining = n.appearance.effects.clone(); + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + let fx = n.appearance.effects.get(index)?; + let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); + // An object's surface uses its top visible fill, falling back to its stroke. + let item = n + .appearance + .items + .iter() + .rev() + .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) + .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))?; + let (paint, opacity, blend) = paint_of(item); + let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); + children.push(Arc::new(surface(n, &path, fx, paint, opacity, blend, expand)?)); + // Later geometry acts on the projected art as a whole through container evaluation. + remaining = n + .appearance + .effects + .iter() + .enumerate() + .filter(|(i, e)| e.id != REVOLVE && (!is_geometry(&e.id) || *i > index)) + .map(|(_, e)| e.clone()) + .collect(); + } else { + let path = geometry(&path, &n.appearance.effects, &ctx); + remaining.retain(|e| !is_geometry(&e.id)); + for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { + if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) { + let fx = item.effects().get(index)?; + let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); + let (paint, opacity, blend) = paint_of(item); + let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); + let mut art = surface(n, &p, fx, paint, opacity, blend, expand)?; + art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); + children.push(Arc::new(art)); + } else { + children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() }))); + } + } + } + let mut art = n.clone(); + art.kind = NodeKind::Group { children, clip: false }; + art.appearance = Appearance { effects: remaining, ..Default::default() }; + Some(art) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use vectorcraft_doc::{Document, NodeId}; + use vectorcraft_geom::{Affine, BezPath}; + fn profile_node() -> Node { + let mut b = BezPath::new(); + b.move_to((100.0, 40.0)); + b.line_to((130.0, 60.0)); + b.line_to((130.0, 140.0)); + b.line_to((100.0, 160.0)); + Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0)) + } + fn fx() -> Effect { + crate::new_effect(REVOLVE, &json!({})).unwrap() + } + #[test] + fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() { + let mut n = profile_node(); + let before = n.path_data().unwrap().clone(); + n.appearance.effects.push(fx()); + let a = revolve_art(&n).unwrap(); + assert!(!a.children().unwrap().is_empty()); + assert_eq!(n.path_data().unwrap(), &before); + let b = a.visual_bounds().unwrap(); + n.transform(Affine::translate((80.0, 20.0)), false); + let moved = revolve_art(&n).unwrap().visual_bounds().unwrap(); + assert!((moved.x0 - b.x0 - 80.0).abs() < 1e-6 && (moved.y0 - b.y0 - 20.0).abs() < 1e-6); + n.appearance.effects[0].visible = false; + assert!(revolve_art(&n).is_none()); + } + #[test] + fn item_effect_and_transparency_are_scoped_to_whole_surface() { + let mut n = profile_node(); + n.opacity = 0.6; + n.appearance.items[0].effects_mut().push(fx()); + if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] { + f.opacity = 0.4; + } + let a = revolve_art(&n).unwrap(); + assert_eq!(a.opacity, 0.6); + let surface = &a.children().unwrap()[0]; + assert_eq!(surface.opacity, 0.4); + assert!(surface.children().unwrap().iter().all(|c| c.opacity == 1.0 && !has_revolve(c))); + } + #[test] + fn export_bakes_faces_with_unique_ids_and_no_revolve_effects() { + let mut d = Document::new(300.0, 300.0); + let mut n = profile_node(); + n.id = d.alloc_id(); + n.appearance.effects.push(fx()); + let mut stroke = |_: &mut Document, _: &PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None; + let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap(); + let mut ids = std::collections::HashSet::new(); + fn visit(n: &Node, ids: &mut std::collections::HashSet) { + assert!(ids.insert(n.id)); + assert!(!has_revolve(n)); + for c in n.children().into_iter().flatten() { + visit(c, ids); + } + } + visit(&art, &mut ids); + assert!(ids.len() > 50); + } + + #[test] + fn visible_surface_option_only_affects_expansion_and_can_keep_all_faces() { + let mut n = profile_node(); + n.appearance.effects.push(fx()); + let source = n.path_data().unwrap().clone(); + let live = revolve_art(&n).unwrap(); + let expanded = expand_revolve_art(&n).unwrap(); + let count = |n: &Node| n.children().unwrap()[0].children().unwrap().len(); + assert!(count(&expanded) < count(&live) * 3 / 4, "hidden back surfaces are removed"); + assert_eq!(crate::bake_appearance(&n), Some(live.clone()), "other baking retains the full surface"); + n.appearance.effects[0].params["expandVisibleOnly"] = json!(false); + assert_eq!(revolve_art(&n), Some(live.clone()), "the live view ignores the expansion setting"); + assert_eq!(expand_revolve_art(&n), Some(live)); + assert_eq!(n.path_data(), Some(&source)); + n.appearance.effects[0].params["expandVisibleOnly"] = json!(true); + assert_eq!(expand_revolve_art(&n), Some(expanded.clone())); + // Per-item Revolve uses the same option, without trimming another item's artwork. + let effect = n.appearance.effects.remove(0); + n.appearance.items[0].effects_mut().push(effect); + assert_eq!(expand_revolve_art(&n).unwrap().children().unwrap()[0], expanded.children().unwrap()[0]); + } + + #[test] + fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() { + let mut n = profile_node(); + n.appearance.effects.push(fx()); + for change in 0..4 { + let mut sample = n.clone(); + match change { + 0 => sample.opacity = 0.5, + 1 => { + if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] { + f.opacity = 0.4; + } + } + 2 => sample.blend = vectorcraft_doc::color::BlendMode::Multiply, + _ => sample.appearance.effects.push(crate::new_effect("distort.twist", &json!({"angle":45})).unwrap()), + } + assert_eq!(expand_revolve_art(&sample), revolve_art(&sample), "preserve surface for case {change}"); + } + let mut sample = n; + if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] { + f.paint = Paint::Pattern { pattern: "unknown transparency".into(), xf: Affine::IDENTITY }; + } + assert_eq!(expand_revolve_art(&sample), revolve_art(&sample)); + } +} diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 1d3c66a7c..328ffa05c 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -107,6 +107,7 @@ fn edit_effects( let Some(n) = d.node_mut(*id) else { continue }; let index = item.effects_item(&n.appearance, cmd)?; if n.appearance.effects_mut(index).is_some_and(&mut f) { + check_revolve_count(n, cmd)?; done.push(*id); } } @@ -117,6 +118,14 @@ fn edit_effects( }) } +fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> { + let count = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects())).filter(|e| e.id == effects::REVOLVE).count(); + if count > 1 { + return Err(bad(cmd, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); + } + Ok(()) +} + pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.apply"; let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?; @@ -140,6 +149,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true })); } let item = item_target(s, p, C)?; + if id == effects::REVOLVE { + for id in appearance_targets(s, p)? { + let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?; + if effects::has_revolve(n) { + return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); + } + } + } let mut index = 0; let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| { fx.push(effect.clone()); @@ -237,6 +255,7 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { let Some(fx) = n.appearance.effects_mut(di) else { continue }; let at = to.min(fx.len()); fx.insert(at, e); + check_revolve_count(n, C)?; landed.get_or_insert((at, di)); done.push(*id); } @@ -258,6 +277,20 @@ fn set_params(s: &mut Session, p: &Value) -> Result { } let visible = p.get("visible").and_then(Value::as_bool); let item = item_target(s, p, C)?; + for id in appearance_targets(s, p)? { + let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + let target = item.effects_item(&n.appearance, C)?; + if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index)) + && e.id == effects::REVOLVE + && visible != Some(false) + { + let mut prospective = effects::merged_params(&e.id, &e.params); + if let (Some(cur), Some(new)) = (prospective.as_object_mut(), params.as_object()) { + cur.extend(new.clone()); + } + effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?; + } + } let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| { let Some(e) = fx.get_mut(index) else { return false }; if let (Value::Object(new), cur) = (¶ms, &mut e.params) { @@ -454,6 +487,7 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { mod tests { use serde_json::json; use vectorcraft_doc::NodeKind; + use vectorcraft_render::effects; use crate::{NodeId, Session}; @@ -468,6 +502,71 @@ mod tests { s.doc().unwrap().doc.node(id).cloned().unwrap() } + #[test] + fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let source = node(&s, id).path_data().unwrap().clone(); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); + let before = effects_for_test(&s, id); + assert_eq!(node(&s, id).path_data(), Some(&source)); + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().path_data(), Some(&source)); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), before); + let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + assert!(s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).is_err()); + assert!(s.execute("effect.duplicate", &json!({"index":0,"ids":[id.0],"item":null})).is_err()); + s.execute("effect.setParams", &json!({"index":0,"params":{"angle":180,"offset":10},"ids":[id.0],"item":null})).unwrap(); + assert_ne!(effects_for_test(&s, id), before); + assert!(s.execute("effect.setParams", &json!({"index":0,"params":{"edge":"invalid"},"ids":[id.0],"item":null})).is_err()); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(matches!(node(&s, id).kind, NodeKind::Group { .. })); + assert!(!effects::has_revolve(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).path_data(), Some(&source)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(effects_for_test(&s, id), before); + } + + fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + effects::revolve_art(&node(s, id)).unwrap() + } + + #[test] + fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); + let live = node(&s, id); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true)); + let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + let visible = count(&node(&s, id)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), live); + s.execute("effect.setParams", &json!({"index":0,"item":null,"ids":[id.0],"params":{"expandVisibleOnly":false}})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(count(&node(&s, id)) > visible); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).appearance.effects[0].params["expandVisibleOnly"], json!(false)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), live); + // The shared command still expands ordinary multiple-fill artwork as before. + let r = s.execute("shape.rectangle", &json!({"x":10,"y":10,"width":20,"height":20})).unwrap(); + let ordinary = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"distort.twist","ids":[ordinary.0],"params":{"angle":15}})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[ordinary.0]})).unwrap(); + assert!(node(&s, ordinary).appearance.effects.is_empty()); + } + #[test] fn apply_uses_catalog_defaults_and_undoes() { let (mut s, id) = session_with_rect(); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 38bc06bec..266bfe0b5 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -61,6 +61,7 @@ pub(crate) fn has_object_fx(n: &Node) -> bool { || effects::has_container_appearance(n) || effects::has_crop_marks(n) || effects::has_adjustment(n) + || effects::has_revolve(n) } /// Is `n` a group or layer (whose evaluated art keeps its knockout setting)? @@ -123,6 +124,12 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] { /// Visual bounds including geometry effects, stroke outsets and shadows/glows. pub(crate) fn visual_bounds(n: &Node) -> Option { + if let Some(art) = effects::revolve_art(n) { + return cull_bounds(&art).map(|b| { + let o = effects::outset(&n.appearance.effects, b); + b.inflate(o, o) + }); + } let bp = node_bezpath(n)?; let ctx = GeomContext::of(n); let g = effected_path(n, &bp); @@ -408,12 +415,13 @@ impl Renderer { _ => None, }; let container = is_container(a); - let art = match effects::crop_marks_art(a).or_else(|| effects::reshape(a, symbol.as_ref())) { + let art = match effects::crop_marks_art(a).or_else(|| effects::revolve_art(a)).or_else(|| effects::reshape(a, symbol.as_ref())) { Some(r) => r, None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()), None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(), None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; + let art = if effects::has_revolve(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art }; let mut art = self.adjusted_art(doc, art, effects::color_map(&a.appearance.effects).as_ref()); // A path's geometry effects still apply when it is drawn. let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 1026829ec..9041b1076 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -40,6 +40,69 @@ fn lum(p: [u8; 4]) -> u32 { const WHITE: u32 = 765; +#[test] +fn revolve_canvas_matches_baked_art_and_shades_its_surface() { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((50.0, 15.0)); + bp.line_to((75.0, 30.0)); + bp.line_to((75.0, 75.0)); + bp.line_to((50.0, 90.0)); + let mut n = Node::path( + NodeId(0), + vectorcraft_geom::PathData::from_bezpath(&bp), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.2, 0.1)), Paint::None, 0.0), + ); + n.appearance.effects.push(fx("threeD.revolve", json!({"rotationX":0,"rotationY":0}))); + let d = doc_with(Document::new(100.0, 100.0), n); + let live = render(&d); + let baked = vectorcraft_effects::bake_document(&d).unwrap(); + let exported = render(&baked); + assert_eq!(live.pixels, exported.pixels, "canvas and baked/exported faces agree"); + let reds: Vec<_> = + live.pixels.as_chunks::<4>().0.iter().filter(|p| p[0] as u16 > p[1] as u16 * 2 && p[0] as u16 > p[2] as u16 * 2).map(|p| p[0]).collect(); + assert!(reds.len() > 1500, "a filled surface, rather than the open source path"); + assert!(reds.iter().max().unwrap() - reds.iter().min().unwrap() > 80, "lighting varies across the surface"); + assert_eq!(live.pixels, render_threads(&d, 4).pixels); +} + +#[test] +fn revolve_visible_surface_expansion_preserves_the_view_and_transparent_art() { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((50.0, 15.0)); + bp.curve_to((80.0, 25.0), (80.0, 75.0), (50.0, 90.0)); + let mut n = Node::path( + NodeId(0), + vectorcraft_geom::PathData::from_bezpath(&bp), + Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0), + ); + for (angle, rotation, perspective, opacity) in + [(360.0, 0.0, 0.0, 1.0), (360.0, -35.0, 55.0, 1.0), (150.0, -28.0, 40.0, 1.0), (360.0, -35.0, 55.0, 0.5)] + { + n.opacity = opacity; + n.appearance.effects = vec![fx( + "threeD.revolve", + json!({"angle":angle,"rotationX":rotation,"rotationY":25,"perspective":perspective,"segments":32,"expandVisibleOnly":true}), + )]; + let d = doc_with(Document::new(100.0, 100.0), n.clone()); + let live = render(&d); + let mut expanded = d.clone(); + let id = expanded.layers[0].children().unwrap()[0].id; + let source = expanded.node(id).unwrap().clone(); + let mut stroke = |_: &mut Document, _: &vectorcraft_geom::PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None; + let art = vectorcraft_effects::expand_leaf(&mut expanded, &source, &mut stroke).unwrap(); + *expanded.node_mut(id).unwrap() = art; + let flat = render(&expanded); + let difference: u64 = live.pixels.iter().zip(&flat.pixels).map(|(a, b)| a.abs_diff(*b) as u64).sum(); + let average = difference as f64 / live.pixels.len() as f64; + assert!(average < 1.0, "appearance differs by {average} channel levels: angle {angle}, rotation {rotation}, opacity {opacity}"); + if opacity < 1.0 { + assert_eq!(live.pixels, flat.pixels); + } + // Export baking keeps every face, even when the expansion checkbox is checked. + assert_eq!(live.pixels, render(&vectorcraft_effects::bake_document(&d).unwrap()).pixels); + } +} + /// A 30×30 red image placed at (20, 20). fn image_doc(effects: Vec) -> Document { let mut d = Document::new(100.0, 100.0); diff --git a/crates/svg/tests/effects_export.rs b/crates/svg/tests/effects_export.rs index 5e6f433ed..ea63c7fc4 100644 --- a/crates/svg/tests/effects_export.rs +++ b/crates/svg/tests/effects_export.rs @@ -21,6 +21,23 @@ fn fx(id: &str) -> Effect { Effect { id: id.into(), params: json!({}), visible: true } } +#[test] +fn revolve_exports_shaded_vector_faces_and_keeps_the_source_live() { + let bp = vectorcraft_geom::BezPath::from_svg("M100 30 L130 50 L130 140 L100 160").unwrap(); + let mut n = Node::path( + NodeId(0), + vectorcraft_geom::PathData::from_bezpath(&bp), + Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0), + ); + n.appearance.effects.push(fx("threeD.revolve")); + let d = doc_with(n); + let before = d.clone(); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.matches(" 50, "Revolve should export vector surfaces"); + assert!(!svg.contains(" Self { + Self(self.0 - b.0, self.1 - b.1, self.2 - b.2) + } + fn cross(self, b: Self) -> Self { + Self(self.1 * b.2 - self.2 * b.1, self.2 * b.0 - self.0 * b.2, self.0 * b.1 - self.1 * b.0) + } + fn dot(self, b: Self) -> f64 { + self.0 * b.0 + self.1 * b.1 + self.2 * b.2 + } + fn unit(self) -> Self { + let l = self.dot(self).sqrt(); + if l < 1e-12 { Self::default() } else { Self(self.0 / l, self.1 / l, self.2 / l) } + } +} + +/// The vertical axis is offset outward from the selected edge of the profile's bounds. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Edge { + #[default] + Left, + Right, +} + +/// All angles are degrees; offset is in document points, lighting values are 0..1. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Revolve { + pub angle: f64, + pub offset: f64, + pub edge: Edge, + pub rotation_x: f64, + pub rotation_y: f64, + pub rotation_z: f64, + /// Perspective strength, 0 for orthographic projection, up to 1. + pub perspective: f64, + /// Radial subdivisions for a full turn, clamped to 8..128. + pub segments: usize, + pub light_azimuth: f64, + pub light_elevation: f64, + pub light_intensity: f64, + pub ambient: f64, + pub shade: bool, +} + +impl Default for Revolve { + fn default() -> Self { + Self { + angle: 360.0, + offset: 0.0, + edge: Edge::Left, + rotation_x: 0.0, + rotation_y: 0.0, + rotation_z: 0.0, + perspective: 0.0, + segments: 64, + light_azimuth: -45.0, + light_elevation: 45.0, + light_intensity: 0.8, + ambient: 0.25, + shade: true, + } + } +} + +fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 { + if v.is_finite() { v.clamp(min, max) } else { fallback } +} + +impl Revolve { + /// Unit bases of the rings that adjust the Euler X, Y and Z view parameters. + /// X turns before Y and Z, Y before Z, and Z turns in the screen plane. + /// Each pair has a cross product pointing along that parameter's rotation axis. + pub fn rotation_planes(self) -> [[[f64; 3]; 2]; 3] { + let o = self.sanitized(); + let x_frame = Self { rotation_x: 0.0, ..o }; + let y_frame = Self { rotation_x: 0.0, rotation_y: 0.0, ..o }; + [ + [x_frame.rotate(V3(0.0, 1.0, 0.0)), x_frame.rotate(V3(0.0, 0.0, 1.0))], + [y_frame.rotate(V3(1.0, 0.0, 0.0)), y_frame.rotate(V3(0.0, 0.0, -1.0))], + [V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0)], + ] + .map(|plane| plane.map(|v| [v.0, v.1, v.2])) + } + + /// The fixed centre of view rotation, in document coordinates. + pub fn rotation_origin(self, bounds: Rect) -> Point { + let o = self.sanitized(); + Point::new(if o.edge == Edge::Left { bounds.x0 - o.offset } else { bounds.x1 + o.offset }, bounds.center().y) + } + + /// Rotated unit axes for a view-orientation guide (screen Y points down). + pub fn view_axes(self) -> [Point; 3] { + [V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0), V3(0.0, 0.0, 1.0)].map(|v| { + let p = self.sanitized().rotate(v); + Point::new(p.0, p.1) + }) + } + + /// The same projected vertical axis used by the surface evaluator. + pub fn projected_axis(self, bounds: Rect) -> [Point; 2] { + let o = self.sanitized(); + let axis = match o.edge { + Edge::Left => bounds.x0 - o.offset, + Edge::Right => bounds.x1 + o.offset, + }; + let center_y = bounds.center().y; + let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs()); + let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0); + [bounds.y0, bounds.y1].map(|y| { + let v = o.rotate(V3(0.0, y - center_y, 0.0)); + let factor = distance / (distance - v.2 * o.perspective); + Point::new(axis + v.0 * factor, center_y + v.1 * factor) + }) + } + + /// Directional light in view space, shared by shading and interactive guides. + pub fn light_direction(self) -> [f64; 3] { + let o = self.sanitized(); + let (sl, cl) = o.light_elevation.to_radians().sin_cos(); + let (sa, ca) = o.light_azimuth.to_radians().sin_cos(); + [cl * sa, -sl, cl * ca] + } + + /// Light-direction orbits as (centre, cosine basis, sine basis), in view space. + /// The first changes azimuth at the current elevation. The second changes elevation + /// at the current azimuth. The meridian's valid parameter range is -90..90 degrees. + pub fn light_orbits(self) -> [[[f64; 3]; 3]; 2] { + let o = self.sanitized(); + let (se, ce) = o.light_elevation.to_radians().sin_cos(); + let (sa, ca) = o.light_azimuth.to_radians().sin_cos(); + [[[0.0, -se, 0.0], [0.0, 0.0, ce], [ce, 0.0, 0.0]], [[0.0, 0.0, 0.0], [sa, 0.0, ca], [0.0, -1.0, 0.0]]] + } + + /// Move a light on its current hemisphere, preserving the azimuth at the poles. + /// A light behind the object stays behind when its projected handle is dragged. + pub fn light_at_on_side(self, point: Point) -> [f64; 2] { + let o = self.sanitized(); + let point = Point::new(finite(point.x, 0.0, -1e8, 1e8), finite(point.y, 0.0, -1e8, 1e8)); + let length = point.x.hypot(point.y).max(1.0); + let (x, y) = (point.x / length, point.y / length); + let z = (1.0 - x * x - y * y).max(0.0).sqrt().copysign(o.light_direction()[2]); + let azimuth = if x.hypot(z) < 1e-9 { o.light_azimuth } else { x.atan2(z).to_degrees() }; + [azimuth, (-y).clamp(-1.0, 1.0).asin().to_degrees()] + } + + /// A dragged light position on the front hemisphere, in normalized view coordinates. + pub fn light_at(point: Point) -> [f64; 2] { + let length = point.x.hypot(point.y).max(1.0); + let (x, y) = (point.x / length, point.y / length); + let z = (1.0 - x * x - y * y).max(0.0).sqrt(); + [x.atan2(z).to_degrees(), (-y).clamp(-1.0, 1.0).asin().to_degrees()] + } + + fn sanitized(self) -> Self { + Self { + angle: finite(self.angle, 360.0, 0.0, 360.0), + offset: finite(self.offset, 0.0, 0.0, 1e5), + rotation_x: finite(self.rotation_x, 0.0, -360.0, 360.0), + rotation_y: finite(self.rotation_y, 0.0, -360.0, 360.0), + rotation_z: finite(self.rotation_z, 0.0, -360.0, 360.0), + perspective: finite(self.perspective, 0.0, 0.0, 1.0), + segments: self.segments.clamp(8, 128), + light_azimuth: finite(self.light_azimuth, -45.0, -360.0, 360.0), + light_elevation: finite(self.light_elevation, 45.0, -90.0, 90.0), + light_intensity: finite(self.light_intensity, 0.8, 0.0, 1.0), + ambient: finite(self.ambient, 0.25, 0.0, 1.0), + ..self + } + } + fn rotate(self, v: V3) -> V3 { + let (sx, cx) = self.rotation_x.to_radians().sin_cos(); + let (sy, cy) = self.rotation_y.to_radians().sin_cos(); + let (sz, cz) = self.rotation_z.to_radians().sin_cos(); + let v = V3(v.0, cx * v.1 - sx * v.2, sx * v.1 + cx * v.2); + let v = V3(cy * v.0 + sy * v.2, v.1, -sy * v.0 + cy * v.2); + V3(cz * v.0 - sz * v.1, sz * v.0 + cz * v.1, v.2) + } +} + +/// One opaque vector polygon, already projected. Brightness multiplies the source colour. +#[derive(Clone, Debug, PartialEq)] +pub struct Face { + pub points: Vec, + pub depth: f64, + pub brightness: f64, +} + +impl Face { + pub fn path(&self) -> PathData { + let mut bp = BezPath::new(); + if let Some(first) = self.points.first() { + bp.move_to(*first); + for p in self.points.iter().skip(1) { + bp.line_to(*p); + } + bp.close_path(); + } + PathData::from_bezpath(&bp) + } +} + +/// Evaluate a profile in document space. All subpaths share one axis from `bounds`. +/// Invalid or excessively complex inputs return an error, never a partial surface. +pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, String> { + let o = options.sanitized(); + if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { + return Err("Revolve requires finite profile coordinates within 100 million points".into()); + } + let axis = match o.edge { + Edge::Left => bounds.x0 - o.offset, + Edge::Right => bounds.x1 + o.offset, + }; + let center_y = (bounds.y0 + bounds.y1) * 0.5; + let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs()); + let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0); + let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1); + let sweep = o.angle.to_radians(); + let [lx, ly, lz] = o.light_direction(); + let light = V3(lx, ly, lz); + let mut faces = Vec::new(); + let mut count = 0; + for sp in &path.subpaths { + let mut profile = Vec::new(); + if let Some(first) = sp.anchors.first() { + profile.push(first.p); + count += 1; + if count > MAX_PROFILE_POINTS { + return Err("Revolve profile is too complex".into()); + } + } + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + // Bound work before sampling any data. Curves get sixteen samples, straight edges one. + if segments > MAX_PROFILE_POINTS { + return Err("Revolve profile is too complex".into()); + } + for i in 0..segments { + let curve = sp.segment(i); + let samples = if sp.segment_is_line(i) { 1 } else { 16 }; + for k in 1..=samples { + profile.push(curve.eval(k as f64 / samples as f64)); + count += 1; + if count > MAX_PROFILE_POINTS { + return Err("Revolve profile is too complex".into()); + } + } + } + for pair in profile.windows(2) { + let [a, b] = pair else { continue }; + if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { + return Err("Revolve profile contains invalid coordinates".into()); + } + for j in 0..steps { + let vertex = |p: Point, step: usize| { + let (s, c) = (sweep * step as f64 / steps as f64).sin_cos(); + o.rotate(V3((p.x - axis) * c, p.y - center_y, (p.x - axis) * s)) + }; + let vertices = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)]; + // The diagonal gives a nonzero normal even when one profile endpoint is on-axis. + let mut normal = vertices[1].sub(vertices[0]).cross(vertices[2].sub(vertices[0])).unit(); + if normal.dot(normal) < 0.5 { + normal = vertices[2].sub(vertices[0]).cross(vertices[3].sub(vertices[0])).unit(); + } + if normal.dot(normal) < 0.5 { + continue; + } + // Profiles may run up or down. Shade the visible side of this two-sided surface. + if normal.2 < 0.0 { + normal = V3(-normal.0, -normal.1, -normal.2); + } + let brightness = if o.shade { (o.ambient + o.light_intensity * normal.dot(light).max(0.0)).clamp(0.0, 1.0) } else { 1.0 }; + let points: Vec<_> = vertices + .iter() + .map(|v| { + let factor = distance / (distance - v.2 * o.perspective); + Point::new(axis + v.0 * factor, center_y + v.1 * factor) + }) + .collect(); + if points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return Err("Revolve projection is not finite".into()); + } + let area = points + .iter() + .zip(points.iter().cycle().skip(1)) + .take(4) + .map(|(a, b)| (a.x - axis) * (b.y - center_y) - (b.x - axis) * (a.y - center_y)) + .sum::(); + if area.abs() < 1e-10 { + continue; + } + faces.push(Face { points, depth: vertices.iter().map(|v| v.2).sum::() / 4.0, brightness }); + if faces.len() > MAX_FACES { + return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into()); + } + } + } + } + faces.sort_by(|a, b| a.depth.total_cmp(&b.depth)); + Ok(faces) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() { + for azimuth in [-170.0f64, -45.0, 0.0, 83.0, 167.0] { + for elevation in [-85.0f64, -30.0, 0.0, 45.0, 90.0] { + let o = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..Default::default() }; + let direction = o.light_direction(); + for ([center, u, v], angle) in o.light_orbits().into_iter().zip([azimuth, elevation]) { + let (s, c) = angle.to_radians().sin_cos(); + for i in 0..3 { + assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12); + } + } + let [a, e] = o.light_at_on_side(Point::new(direction[0], direction[1])); + assert!((a - azimuth).abs() < 1e-9 && (e - elevation).abs() < 1e-9, "{azimuth}, {elevation} -> {a}, {e}"); + } + } + let o = Revolve { light_azimuth: 135.0, ..Default::default() }; + let [azimuth, elevation] = o.light_at_on_side(Point::new(0.3, -0.4)); + let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction(); + assert!((x - 0.3).abs() < 1e-12 && (y + 0.4).abs() < 1e-12 && z < 0.0); + let [a, e] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY)); + assert!(a.is_finite() && e.is_finite()); + } + + #[test] + fn ring_axes_match_euler_parameter_rotation_and_origin_stays_fixed() { + let o = Revolve { rotation_x: 27.0, rotation_y: -39.0, rotation_z: 53.0, ..Default::default() }; + let seed = V3(0.3, -0.5, 0.7); + let before = o.rotate(seed); + let planes = o.rotation_planes(); + for (axis, [u, v]) in planes.iter().enumerate() { + let normal = V3(u[0], u[1], u[2]).cross(V3(v[0], v[1], v[2])); + assert!((normal.dot(normal) - 1.0).abs() < 1e-12); + let mut changed = o; + match axis { + 0 => changed.rotation_x += 0.0001, + 1 => changed.rotation_y += 0.0001, + _ => changed.rotation_z += 0.0001, + } + let actual = changed.rotate(seed).sub(before); + let expected = normal.cross(before); + let step = 0.0001f64.to_radians(); + for (a, b) in [(actual.0 / step, expected.0), (actual.1 / step, expected.1), (actual.2 / step, expected.2)] { + assert!((a - b).abs() < 1e-5); + } + } + assert_eq!(Revolve { edge: Edge::Right, offset: 12.0, ..o }.rotation_origin(Rect::new(20.0, 30.0, 80.0, 100.0)), Point::new(92.0, 65.0)); + } + #[test] + fn guides_match_projection_and_dragged_light_matches_shading_direction() { + let o = Revolve { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, offset: 10.0, ..Default::default() }; + assert_eq!(o.view_axes(), [Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]); + assert_eq!(o.projected_axis(Rect::new(20.0, 30.0, 80.0, 100.0)), [Point::new(10.0, 30.0), Point::new(10.0, 100.0)]); + let [azimuth, elevation] = Revolve::light_at(Point::new(0.3, -0.4)); + let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction(); + assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0); + let [azimuth, elevation] = Revolve::light_at(Point::new(2.0, -2.0)); + assert!(azimuth.is_finite() && elevation.is_finite() && elevation.abs() <= 90.0); + } + fn cylinder() -> (PathData, Rect) { + let mut bp = BezPath::new(); + bp.move_to((40.0, 10.0)); + bp.line_to((40.0, 90.0)); + (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0)) + } + #[test] + fn cylinder_has_expected_silhouette_depth_order_and_shading() { + let (p, b) = cylinder(); + let faces = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + let points: Vec<_> = faces.iter().flat_map(|f| &f.points).collect(); + assert!((points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min) + 40.0).abs() < 1e-6); + assert!((points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max) - 40.0).abs() < 1e-6); + assert!(faces.windows(2).all(|w| w[0].depth <= w[1].depth)); + assert!(faces.iter().any(|f| f.brightness > 0.8) && faces.iter().any(|f| f.brightness < 0.4)); + } + #[test] + fn edge_offset_sweep_and_rotation_change_the_surface() { + let (p, b) = cylinder(); + let base = revolve(&p, b, Revolve::default()).unwrap(); + for o in [ + Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() }, + Revolve { angle: 120.0, ..Default::default() }, + Revolve { rotation_z: 60.0, perspective: 0.8, ..Default::default() }, + ] { + assert_ne!(base, revolve(&p, b, o).unwrap()); + } + assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty()); + } + #[test] + fn malformed_and_degenerate_inputs_are_bounded() { + let (p, b) = cylinder(); + let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap(); + assert!(f.len() <= 128); + assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err()); + assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty()); + } +} diff --git a/crates/three-d/src/visibility.rs b/crates/three-d/src/visibility.rs new file mode 100644 index 000000000..ce2bfa744 --- /dev/null +++ b/crates/three-d/src/visibility.rs @@ -0,0 +1,325 @@ +//! Vector visibility in the evaluator's paint order. Convex projected faces are cut by the +//! nearer faces covering them, irrespective of their winding or which side faces the camera. +//! This preserves interiors visible through open ends and partial sweeps. + +use vectorcraft_geom::{PathData, Point, Rect, SubPath}; + +use crate::{Face, MAX_FACES}; + +const GRID: usize = 32; +const MAX_GRID_REFS: usize = 2_000_000; +const MAX_WORK: usize = 16_000_000; +const MAX_FRAGMENTS: usize = 256; +const MAX_OUTPUT_POINTS: usize = 262_144; + +/// The visible portions of one source face, with its original shading and paint order. +#[derive(Clone, Debug, PartialEq)] +pub struct VisibleFace { + pub path: PathData, + pub brightness: f64, + pub depth: f64, +} + +struct Polygon { + points: Vec, + bounds: Rect, +} + +fn bounds(points: &[Point]) -> Rect { + let Some(first) = points.first() else { return Rect::ZERO }; + points.iter().fold(Rect::from_points(*first, *first), |b, p| b.union_pt(*p)) +} + +fn area(points: &[Point]) -> f64 { + let Some(origin) = points.first() else { return 0.0 }; + points.iter().zip(points.iter().cycle().skip(1)).take(points.len()).map(|(a, b)| (*a - *origin).cross(*b - *origin)).sum::() / 2.0 +} + +fn clean(mut points: Vec, epsilon: f64) -> Vec { + points.dedup_by(|a, b| a.distance(*b) <= epsilon); + if points.len() > 1 && points.first().zip(points.last()).is_some_and(|(a, b)| a.distance(*b) <= epsilon) { + points.pop(); + } + if points.len() < 3 || area(&points).abs() <= epsilon * epsilon { + return vec![]; + } + if area(&points) < 0.0 { + points.reverse(); + } + points +} + +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 +} + +/// Split a convex polygon at an occluder's edge. Boundary vertices belong to both halves, +/// so independently clipped neighbours share precisely the same intersection positions. +fn split(points: &[Point], a: Point, b: Point, epsilon: f64) -> (Vec, Vec) { + let edge = b - a; + let mut inside = vec![]; + let mut outside = vec![]; + for (p, q) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) { + let dp = edge.cross(*p - a); + let dq = edge.cross(*q - a); + if dp >= 0.0 { + inside.push(*p); + } + if dp <= 0.0 { + outside.push(*p); + } + if (dp > 0.0 && dq < 0.0) || (dp < 0.0 && dq > 0.0) { + let intersection = p.lerp(*q, dp / (dp - dq)); + inside.push(intersection); + outside.push(intersection); + } + } + (clean(inside, epsilon), clean(outside, epsilon)) +} + +fn subtract(points: Vec, cover: &Polygon, epsilon: f64, work: &mut usize) -> Result>, String> { + if !overlaps(bounds(&points), cover.bounds) { + return Ok(vec![points]); + } + let mut remaining = points; + let mut visible = vec![]; + for (a, b) in cover.points.iter().zip(cover.points.iter().cycle().skip(1)).take(cover.points.len()) { + *work += remaining.len(); + if *work > MAX_WORK { + return Err("Revolve visibility exceeds its geometry work limit".into()); + } + let (inside, outside) = split(&remaining, *a, *b, epsilon); + if !outside.is_empty() { + visible.push(outside); + } + remaining = inside; + if remaining.is_empty() { + break; + } + } + Ok(visible) +} + +struct Index { + cells: Vec>, + bounds: Rect, + refs: usize, +} + +impl Index { + fn tiles(&self, b: Rect) -> impl Iterator { + let cell = |v: f64, lo: f64, size: f64| (((v - lo) / size * GRID as f64).floor() as usize).min(GRID - 1); + let x0 = cell(b.x0, self.bounds.x0, self.bounds.width()); + let x1 = cell(b.x1, self.bounds.x0, self.bounds.width()); + let y0 = cell(b.y0, self.bounds.y0, self.bounds.height()); + let y1 = cell(b.y1, self.bounds.y0, self.bounds.height()); + (y0..=y1).flat_map(move |y| (x0..=x1).map(move |x| y * GRID + x)) + } + + fn insert(&mut self, b: Rect, face: usize) -> Result<(), String> { + let tiles: Vec<_> = self.tiles(b).collect(); + self.refs += tiles.len(); + if self.refs > MAX_GRID_REFS { + return Err("Revolve visibility exceeds its spatial index limit".into()); + } + for tile in tiles { + if let Some(cell) = self.cells.get_mut(tile) { + cell.push(face); + } + } + Ok(()) + } +} + +/// Remove covered portions of opaque faces in back-to-front paint order. This follows the +/// live evaluator's painter visibility, including its approximation for intersecting surfaces. +/// Resource limits return an error; callers can retain the original complete surface safely. +pub fn visible_faces(faces: &[Face]) -> Result, String> { + if faces.len() > MAX_FACES { + return Err("Too many Revolve faces for visibility evaluation".into()); + } + if faces.iter().any(|f| { + f.points.len() > 4 + || !f.brightness.is_finite() + || !f.depth.is_finite() + || f.points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8) + }) { + return Err("Revolve visibility requires finite projected convex faces".into()); + } + let all = faces.iter().flat_map(|f| &f.points).copied().collect::>(); + let total = bounds(&all); + if total.width() <= 0.0 || total.height() <= 0.0 { + return Ok(vec![]); + } + let epsilon = (total.width().max(total.height()) * 1e-10).clamp(1e-8, 1e-3); + let polygons: Vec<_> = faces + .iter() + .map(|f| { + let points = clean(f.points.clone(), epsilon); + Polygon { bounds: bounds(&points), points } + }) + .collect(); + // Generated quads are convex. Validate the public helper rather than accepting concave + // callers and silently deleting geometry they can see. + for p in &polygons { + if p.points.iter().enumerate().any(|(i, a)| { + let Some(b) = p.points.get((i + 1) % p.points.len()) else { return false }; + let Some(c) = p.points.get((i + 2) % p.points.len()) else { return false }; + (*b - *a).cross(*c - *b) < -epsilon * b.distance(*a).max(1.0) + }) { + return Err("Revolve visibility requires convex projected faces".into()); + } + } + let mut index = Index { cells: vec![vec![]; GRID * GRID], bounds: total, refs: 0 }; + let mut seen = vec![usize::MAX; faces.len()]; + let (mut out, mut work, mut output_points) = (vec![], 0, 0); + for (source, (face, polygon)) in faces.iter().zip(&polygons).enumerate().rev() { + if polygon.points.is_empty() { + continue; + } + let mut fragments = vec![polygon.points.clone()]; + 'covers: for tile in index.tiles(polygon.bounds) { + for &candidate in index.cells.get(tile).into_iter().flatten() { + work += 1; + if work > MAX_WORK { + return Err("Revolve visibility exceeds its geometry work limit".into()); + } + if let Some(mark) = seen.get_mut(candidate) + && *mark != source + { + *mark = source; + if let Some(cover) = polygons.get(candidate) + && overlaps(polygon.bounds, cover.bounds) + { + let mut remaining = vec![]; + for fragment in fragments { + remaining.extend(subtract(fragment, cover, epsilon, &mut work)?); + if remaining.len() > MAX_FRAGMENTS { + return Err("Revolve visibility creates too many fragments".into()); + } + } + fragments = remaining; + if fragments.is_empty() { + break 'covers; + } + } + } + } + } + if fragments.is_empty() { + continue; + } + output_points += fragments.iter().map(Vec::len).sum::(); + if output_points > MAX_OUTPUT_POINTS { + return Err("Revolve visibility creates too many vector anchors".into()); + } + let path = PathData::new(fragments.into_iter().map(|p| SubPath::polyline(&p, true)).collect()); + out.push(VisibleFace { path, brightness: face.brightness, depth: face.depth }); + // A completely covered face need not occupy the grid. Its coverage already belongs + // to nearer faces; this keeps repeated overlapping surfaces bounded in practice. + index.insert(polygon.bounds, source)?; + } + out.reverse(); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Edge, Revolve, revolve}; + use vectorcraft_geom::{BezPath, Shape}; + + fn face(rect: Rect, brightness: f64) -> Face { + Face { + points: vec![Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)], + depth: brightness, + brightness, + } + } + + #[test] + fn covered_faces_disappear_and_partial_faces_are_trimmed_in_paint_order() { + let faces = + vec![face(Rect::new(2.0, 2.0, 8.0, 8.0), 0.1), face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.2), face(Rect::new(5.0, 0.0, 15.0, 10.0), 0.8)]; + let visible = visible_faces(&faces).unwrap(); + assert_eq!(visible.len(), 2); + assert_eq!(visible[0].brightness, 0.2); + assert_eq!(visible[0].path.bounds(), Some(Rect::new(0.0, 0.0, 5.0, 10.0))); + assert!((visible[0].path.to_bezpath().area() - 50.0).abs() < 1e-8); + assert!((visible[1].path.to_bezpath().area() - 100.0).abs() < 1e-8); + // Winding and equal depth do not determine visibility; actual paint order does. + let mut flipped = faces.clone(); + for f in &mut flipped { + f.points.reverse(); + f.depth = 0.0; + } + let other = visible_faces(&flipped).unwrap(); + assert_eq!(visible.iter().map(|f| f.brightness).collect::>(), other.iter().map(|f| f.brightness).collect::>()); + assert_eq!(visible[0].path.bounds(), other[0].path.bounds()); + } + + #[test] + fn an_interior_surface_remains_visible_through_an_opening() { + let faces = vec![ + face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.1), + face(Rect::new(0.0, 0.0, 10.0, 3.0), 0.2), + face(Rect::new(0.0, 7.0, 10.0, 10.0), 0.3), + face(Rect::new(0.0, 3.0, 3.0, 7.0), 0.4), + face(Rect::new(7.0, 3.0, 10.0, 7.0), 0.5), + ]; + let visible = visible_faces(&faces).unwrap(); + assert_eq!(visible[0].brightness, 0.1); + assert_eq!(visible[0].path.bounds(), Some(Rect::new(3.0, 3.0, 7.0, 7.0))); + assert_eq!(visible[0].path.to_bezpath().winding(Point::new(5.0, 5.0)), 1); + assert_eq!(visible[0].path.to_bezpath().winding(Point::new(1.0, 1.0)), 0); + } + + #[test] + fn revolved_open_profiles_match_visible_paint_for_both_axis_sides_and_perspective() { + let mut bp = BezPath::new(); + bp.move_to((40.0, 10.0)); + bp.curve_to((75.0, 20.0), (75.0, 80.0), (40.0, 90.0)); + let path = PathData::from_bezpath(&bp); + for edge in [Edge::Left, Edge::Right] { + for angle in [155.0, 360.0] { + let options = Revolve { + edge, + angle, + offset: 8.0, + segments: 32, + rotation_x: -32.0, + rotation_y: 24.0, + rotation_z: 19.0, + perspective: 0.7, + ..Default::default() + }; + let faces = revolve(&path, path.bounds().unwrap(), options).unwrap(); + let visible = visible_faces(&faces).unwrap(); + assert!(visible.len() <= faces.len()); + let all: Vec<_> = faces.iter().map(|f| (f.path().to_bezpath(), f.brightness)).collect(); + let cut: Vec<_> = visible.iter().map(|f| (f.path.to_bezpath(), f.brightness)).collect(); + let b = bounds(&faces.iter().flat_map(|f| &f.points).copied().collect::>()); + for x in 0..48 { + for y in 0..48 { + let p = Point::new(b.x0 + b.width() * (x as f64 + 0.337) / 48.0, b.y0 + b.height() * (y as f64 + 0.413) / 48.0); + let expected = all.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, value)| value); + let actual = cut.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, value)| value); + assert_eq!(actual, expected, "{edge:?}, {angle}, {p:?}"); + assert!(cut.iter().filter(|(bp, _)| bp.winding(p) != 0).count() <= 1, "expanded surfaces do not cover hidden regions"); + } + } + } + } + } + + #[test] + fn visibility_is_bounded_for_repeated_faces_and_rejects_invalid_input() { + let faces = vec![face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.5); MAX_FACES]; + assert_eq!(visible_faces(&faces).unwrap().len(), 1); + assert!(visible_faces(&vec![faces[0].clone(); MAX_FACES + 1]).is_err()); + let mut bad = faces[0].clone(); + bad.points[0].x = f64::NAN; + assert!(visible_faces(&[bad]).is_err()); + assert!(visible_faces(&[]).unwrap().is_empty()); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 872d33ead..d3299868a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -19,6 +19,7 @@ vectorcraft-tools = { workspace = true } vectorcraft-engine = { workspace = true } vectorcraft-svg = { workspace = true } vectorcraft-effects = { workspace = true } +vectorcraft-three-d = { workspace = true } vectorcraft-plugins = { workspace = true } vectorcraft-pdf = { workspace = true } vectorcraft-cad = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ea2ed01bd..8cfcfe589 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -175,7 +175,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag()); // A press on the canvas while the context menu is open only closes it (a drag too, which egui // alone would leave open); the tool doesn't get it. - if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { + if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) { + // The artboard's 3D gizmo owns these gestures while its options are open. + // Do not let a ring drag also move or deselect the editable source path. + } else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { egui::Popup::close_all(ui.ctx()); } else { handle_input(app, ui, &resp, rect); @@ -401,7 +404,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } // Selection visuals and tool overlays. - if app.ui.view.edges { + let revolve_edit = app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active); + if app.ui.view.edges && !revolve_edit { hover_highlight(app, &painter, &xf); selection_overlay(app, &painter, &xf); if app.ui.view.text_threads { @@ -413,7 +417,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } let view_info = app.view_info(); // View → Hide Gradient Annotator hides the Gradient tool's annotator. - if app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator { + if !revolve_edit && (app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator) { let overlays = app.session.overlays(view_info); draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs)); } @@ -423,7 +427,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if app.ui.view.rulers && app.ui.screen_mode < 3 { rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t); } - if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 { + if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 { task_bar(app, ui, &xf); } if app.ui.screen_mode < 3 { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2d029eef3..224d5dff9 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -809,10 +809,14 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", theme::semibold(11.0), t.text); ui.add_space(6.0); let mut job = egui::text::LayoutJob::default(); - let tool = app.session.tool_id(); - // The Zoom tool's drag zooms to an area, or with Animated Zoom zooms as it goes. - let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool }; - for (txt, bold) in hint_segments(hint) { + let segments = if let Some(d) = app.ui.dialog.as_ref().filter(|d| crate::dialogs::revolve_gizmo::active(d)) { + vec![(crate::dialogs::revolve_gizmo::hint(d).into(), false)] + } else { + let tool = app.session.tool_id(); + let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool }; + hint_segments(hint) + }; + for (txt, bold) in segments { let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() }); } diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index e1d21a604..1337e3413 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -127,6 +127,12 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (id, relative) = (d.str("__effect"), d.bool("relative")); + if id == vectorcraft_effects::REVOLVE { + super::revolve::body(app, ui, d); + let (cmd, params) = command(d); + form::preview(app, ui, d, &d.str("__label"), cmd, params); + return false; + } // Plug-in effects get fields from their parameter schema; colour adjustments their sliders. let changed = match vectorcraft_plugins::effect::installed(&id) { Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params), diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 78e7fd8b7..09b9c290e 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -67,6 +67,8 @@ pub mod raster_effects; pub mod rearrange_artboards; pub mod recolor; mod recovery; +mod revolve; +pub(crate) mod revolve_gizmo; pub mod saturate; mod save_changes; pub mod save_for_web; @@ -355,6 +357,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { // its first field is to take the keyboard focus (`focus_id`, until a field takes it). let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending")); let Some(mut d) = app.ui.dialog.clone() else { + revolve_gizmo::clear(ctx); app.ui.dialog_file = None; ctx.data_mut(|m| { m.remove::(shown_id); @@ -362,6 +365,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); return; }; + if !revolve_gizmo::active(&d) { + revolve_gizmo::clear(ctx); + } let focus_first = ctx.data_mut(|m| { if m.get_temp::(shown_id).as_deref() != Some(d.kind.as_str()) { m.insert_temp(shown_id, d.kind.clone()); @@ -377,7 +383,14 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let mut cancel = false; let mut discard = false; let heading = (spec.heading)(&d); - modal::show(ctx, &heading, egui::Id::new(("dialog", d.kind.as_str())), -40.0, MARGIN, |ui| { + let revolves = revolve_gizmo::active(&d); + if revolves { + revolve_gizmo::show(app, ctx, &mut d); + } + let initial = if revolves { egui::vec2((ctx.content_rect().width() / 2.0 - 226.0).max(0.0), -40.0) } else { egui::vec2(0.0, -40.0) }; + let id = egui::Id::new(("dialog", d.kind.as_str())); + let id = if revolves { id.with("revolve") } else { id }; + modal::show_at(ctx, &heading, id, initial, MARGIN, |ui| { // Never wider than the window (a large UI scale in a small window): the text wraps. let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP); ui.set_min_width(spec.min_width.min(room)); diff --git a/crates/ui-egui/src/dialogs/modal.rs b/crates/ui-egui/src/dialogs/modal.rs index dcd415739..164471fc7 100644 --- a/crates/ui-egui/src/dialogs/modal.rs +++ b/crates/ui-egui/src/dialogs/modal.rs @@ -11,6 +11,11 @@ use crate::theme::{self, Tokens}; /// screen's centre until it is moved, with `margin` inside its frame. `add` draws the contents and /// should start with [`heading`] (or [`drag_band`]) so the window can be moved. pub(crate) fn show(ctx: &egui::Context, title: &str, id: Id, nudge: f32, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option { + show_at(ctx, title, id, egui::vec2(0.0, nudge), margin, add) +} + +/// A modal that starts at a chosen offset, then keeps the user's dragged position. +pub(crate) fn show_at(ctx: &egui::Context, title: &str, id: Id, initial: Vec2, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option { let t = Tokens::get(ctx); let dim = backdrop(); egui::Area::new(dim.id).order(dim.order).fixed_pos(egui::Pos2::ZERO).show(ctx, |ui| { @@ -23,7 +28,7 @@ pub(crate) fn show(ctx: &egui::Context, title: &str, id: Id, nudge: f32, marg .collapsible(false) .resizable(false) .title_bar(false) - .anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, nudge)) + .anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, initial)) .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin))) .show(ctx, add) .and_then(|r| r.inner) @@ -67,9 +72,9 @@ fn handle(id: Id) -> Id { /// its [`drag_band`] is dragged, where it sat when the drag began plus how far the pointer has moved /// since the press, kept on screen (applied before the window places itself, so it follows the /// pointer without a frame's lag). -fn offset(ctx: &egui::Context, id: Id, nudge: f32) -> Vec2 { +fn offset(ctx: &egui::Context, id: Id, initial: Vec2) -> Vec2 { let (key, start_key) = (id.with("modal-offset"), id.with("modal-drag-start")); - let offset = ctx.data(|m| m.get_temp::(key)).unwrap_or(egui::vec2(0.0, nudge)); + let offset = ctx.data(|m| m.get_temp::(key)).unwrap_or(initial); let dragged = ctx.read_response(handle(id)).is_some_and(|r| r.dragged()); let moved = ctx.input(|i| Some(i.pointer.latest_pos()? - i.pointer.press_origin()?)); let (true, Some(moved), Some(rect)) = (dragged, moved, ctx.memory(|m| m.area_rect(id))) else { diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs new file mode 100644 index 000000000..2d2b09ccc --- /dev/null +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -0,0 +1,233 @@ +//! Compact Revolve controls. The object and rotation gizmo live on the artboard. +//! All document changes run through the effect dialog's existing preview/commit commands. + +use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2}; +use serde_json::{Value, json}; +use vectorcraft_doc::Unit; +use vectorcraft_effects::REVOLVE; + +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, scrub, widgets}; + +const LABEL_W: f32 = 102.0; +const VALUE_W: f32 = 74.0; +const SIDE_W: f32 = 354.0; + +/// Sized to the screen, with scrolling inside the body so OK and Cancel stay reachable. +pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + // Values start in drag mode; clicking or Tab enters their text editor. + ui.data_mut(|m| m.remove::(widgets::dialog_focus_flag())); + let screen = ui.ctx().content_rect(); + let width = (screen.width() - 64.0).clamp(240.0, SIDE_W); + let height = (screen.height() - 240.0).clamp(120.0, 340.0); + ui.set_width(width); + egui::ScrollArea::vertical().id_salt("revolve-body").max_height(height).auto_shrink([false, true]).show(ui, |ui| { + controls(app, ui, d); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + let t = Tokens::get(ui.ctx()); + ui.allocate_ui_with_layout(vec2((width - 100.0).max(80.0), 32.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add(egui::Label::new(egui::RichText::new(super::revolve_gizmo::hint(d)).size(11.0).color(t.text_dim)).wrap()); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let defaults = vectorcraft_effects::default_params(REVOLVE).unwrap_or(json!({})); + let changed = defaults.as_object().is_some_and(|m| { + m.iter().any(|(k, v)| v.as_f64().map_or_else(|| d.fields.get(k) != Some(v), |default| (d.f64(k, default) - default).abs() > 1e-6)) + }); + let (r, _) = ui.allocate_exact_size(vec2(88.0, 24.0), Sense::hover()); + let response = ui.interact(r, Id::new("revolve-reset-all"), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset all"))); + if response.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + ui.painter().text(r.left_center() + vec2(4.0, 0.0), Align2::LEFT_CENTER, tl!("Reset all"), FontId::proportional(11.5), t.text); + dot(ui, r.right_center() - vec2(10.0, 0.0), changed); + if response.on_hover_text(tl!("Reset all Revolve settings to their defaults.")).clicked() + && let Some(defaults) = defaults.as_object() + { + d.fields.extend(defaults.clone()); + } + }); + }); +} + +fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + ui.spacing_mut().item_spacing.y = 4.0; + let lighting = super::revolve_gizmo::lighting(d); + if let Some(tab) = widgets::tab_bar(ui, &[tl!("Rotation"), tl!("Lighting")], usize::from(lighting)) { + d.fields.insert("__gizmo".into(), json!(if tab == 1 { "light" } else { "rotation" })); + } + ui.add_space(8.0); + if super::revolve_gizmo::lighting(d) { + lighting_controls(ui, d); + return; + } + let unit = app.session.general_unit(); + heading(ui, tl!("Shape")); + number(ui, d, "angle", tl!("Revolve angle"), (0.0, 360.0), "°", 1, None); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + label(ui, tl!("Axis side")); + let edge = d.str("edge"); + let current = if edge == "right" { tl!("Right") } else { tl!("Left") }; + let width = VALUE_W + rail_width(ui) + 6.0; + if let Some(k) = widgets::dropdown(ui, "revolve-edge", current, &[tl!("Left"), tl!("Right")], width) { + d.fields.insert("edge".into(), json!(if k == 0 { "left" } else { "right" })); + } + if reset_dot(ui, "edge", edge != "left", tl!("Left")) { + d.fields.insert("edge".into(), json!("left")); + } + }); + number(ui, d, "offset", tl!("Offset"), (0.0, 100000.0), "", 1, Some(unit)); + ui.add_space(10.0); + heading(ui, tl!("View")); + for (key, name) in [("rotationX", tl!("Rotation X")), ("rotationY", tl!("Rotation Y")), ("rotationZ", tl!("Rotation Z"))] { + number(ui, d, key, name, (-360.0, 360.0), "°", 1, None); + } + number(ui, d, "perspective", tl!("Perspective"), (0.0, 100.0), "%", 1, None); + ui.add_space(6.0); + egui::CollapsingHeader::new(tl!("Advanced")).id_salt("revolve-advanced").show(ui, |ui| { + number(ui, d, "segments", tl!("Segments"), (8.0, 128.0), "", 0, None); + super::form::check(ui, d, "__showAxis", tl!("Show axis")); + ui.add_space(6.0); + ui.horizontal(|ui| { + let mut visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true); + let response = ui.allocate_ui_with_layout( + vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.checkbox(&mut visible, egui::RichText::new(tl!("Keep visible surfaces only when expanding")).size(12.0)) + }, + ).inner; + if response.on_hover_text(tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.")).changed() { + d.fields.insert("expandVisibleOnly".into(), json!(visible)); + } + if reset_dot(ui, "expandVisibleOnly", !visible, "true") { + d.fields.insert("expandVisibleOnly".into(), json!(true)); + } + }); + }); +} + +fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + let mut shade = d.bool("shade"); + if ui.checkbox(&mut shade, tl!("Shading")).changed() { + d.fields.insert("shade".into(), json!(shade)); + } + ui.add_space((ui.available_width() - 24.0).max(0.0)); + if reset_dot(ui, "shade", !shade, "true") { + d.fields.insert("shade".into(), json!(true)); + } + }); + ui.add_enabled_ui(d.bool("shade"), |ui| { + number(ui, d, "lightAzimuth", tl!("Direction"), (-360.0, 360.0), "°", 1, None); + number(ui, d, "lightElevation", tl!("Elevation"), (-90.0, 90.0), "°", 1, None); + number(ui, d, "lightIntensity", tl!("Intensity"), (0.0, 100.0), "%", 1, None); + number(ui, d, "ambient", tl!("Ambient"), (0.0, 100.0), "%", 1, None); + }); +} + +fn heading(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).font(crate::theme::semibold(13.0)).color(t.text)); +} + +fn label(ui: &mut egui::Ui, name: &str) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 24.0), Sense::hover()); + ui.put(r, egui::Label::new(egui::RichText::new(name).size(12.5).color(t.text)).halign(egui::Align::Min).truncate()).on_hover_text(name); + scrub::note_label(ui, r); +} + +fn rail_width(ui: &egui::Ui) -> f32 { + (ui.available_width() - VALUE_W - 24.0 - 18.0).clamp(40.0, 134.0) +} + +/// A value can be typed, dragged, scrubbed by its label, or moved on its slider. +#[allow(clippy::too_many_arguments)] +fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option) { + let default = vectorcraft_effects::default_params(REVOLVE).and_then(|p| p.get(key).and_then(Value::as_f64)).unwrap_or(0.0); + let from = |n| unit.map_or(n, |u| u.from_pt(n)); + let to = |n| unit.map_or(n, |u| u.to_pt(n)); + let typed = unit.map(|u| widgets::typed_unit(ui.ctx(), u)); + let mut value = from(d.f64(key, default)).clamp(from(range.0), from(range.1)); + let suffix = unit.map_or_else(|| suffix.to_string(), |u| format!(" {}", u.suffix())); + let mut next = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + label(ui, name); + let t = Tokens::get(ui.ctx()); + let response = ui + .scope(|ui| { + ui.visuals_mut().widgets.inactive.weak_bg_fill = t.input; + ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border); + ui.add_sized( + [VALUE_W, 24.0], + egui::DragValue::new(&mut value) + .range(from(range.0)..=from(range.1)) + .speed(if decimals == 0 { 1.0 } else { 0.25 }) + .max_decimals(decimals) + .suffix(&suffix) + .update_while_editing(false) + .custom_parser(|s| { + unit.map_or_else( + || vectorcraft_doc::parse_number(&s.replace(['°', '%'], "")), + |u| typed.unwrap_or(u).parse(s).map(|v| u.from_pt(v)), + ) + }), + ) + }) + .inner; + response.clone().on_hover_text(tl!("Drag values or labels. Double-click a value to type.")); + if response.changed() { + next = Some(value); + } + if let Some(n) = scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32) { + next = Some(n); + } + // Offset's rail covers a useful working range; larger values can still be typed. + let rail_max = if key == "offset" { from(500.0) } else { from(range.1) }; + let rail_min = from(range.0); + let rail = rail_width(ui) + VALUE_W; + let slider = + widgets::color_slider(ui, ("revolve-slider", key), ((value - rail_min) / (rail_max - rail_min)).clamp(0.0, 1.0) as f32, rail, &|_| { + t.input_border + }); + if let (Some(n), _) = slider { + next = Some(rail_min + f64::from(n) * (rail_max - rail_min)); + } + if reset_dot(ui, key, (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) { + next = Some(from(default)); + } + }); + if let Some(n) = next.filter(|n| n.is_finite()) { + let factor = 10f64.powi(decimals as i32); + let n = (n * factor).round() / factor; + d.fields.insert(key.into(), json!(to(n.clamp(from(range.0), from(range.1))))); + } +} + +fn dot(ui: &egui::Ui, p: Pos2, changed: bool) { + let t = Tokens::get(ui.ctx()); + if changed { + ui.painter().circle_filled(p, 3.0, if ui.is_enabled() { t.accent } else { t.text_disabled }); + } else { + ui.painter().circle_stroke(p, 3.0, Stroke::new(1.0, t.text_disabled)); + } +} + +fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool { + let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); + let response = ui.interact(r, Id::new(("revolve-reset", key)), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset to default"))); + if response.hovered() { + ui.painter().rect_filled(r, 3.0, Tokens::get(ui.ctx()).hover); + } + dot(ui, r.center(), changed); + response.on_hover_text(format!("{}: {default}", tl!("Reset to default"))).clicked() +} + +#[cfg(test)] +#[path = "tests_revolve.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs new file mode 100644 index 000000000..e00e1377f --- /dev/null +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -0,0 +1,425 @@ +//! Artboard rotation and light controls for the active Revolve dialog. Gestures edit fields; +//! the existing effect preview applies commands and owns OK, Cancel and undo. +use egui::{Align2, FontId, Id, Pos2, Rect, Sense, Stroke, Vec2, vec2}; +use serde_json::json; +use vectorcraft_effects::{REVOLVE, revolve_options}; +use vectorcraft_geom::Point; +use vectorcraft_three_d::Revolve; + +use crate::{VectorcraftApp, canvas::Xf, state::Dialog, theme::Tokens}; + +const KEYS: [&str; 3] = ["rotationX", "rotationY", "rotationZ"]; +const SAMPLES: usize = 96; + +pub(crate) fn active(d: &Dialog) -> bool { + d.kind == "effect" && d.str("__effect") == REVOLVE +} + +pub(crate) fn lighting(d: &Dialog) -> bool { + d.str("__gizmo") == "light" +} + +pub(crate) fn hint(d: &Dialog) -> &'static str { + if !d.bool("preview") || (lighting(d) && !d.bool("shade")) { + tl!("Drag values or labels. Double-click a value to type.") + } else if lighting(d) { + tl!("Drag the light, or its rings. Shift snaps to 15°.") + } else { + tl!("Drag rings to rotate. Shift snaps to 15°.") + } +} + +fn drag_id() -> Id { + Id::new("revolve-gizmo-drag") +} + +pub(crate) fn dragging(ctx: &egui::Context) -> bool { + ctx.data(|m| m.get_temp::(drag_id())).is_some() +} + +pub(super) fn clear(ctx: &egui::Context) { + ctx.data_mut(|m| m.remove::(drag_id())); +} + +#[derive(Clone, Copy)] +struct Ring { + center: Vec2, + u: Vec2, + v: Vec2, + depth: [f32; 2], +} + +impl Ring { + fn point(self, angle: f32) -> Vec2 { + self.center + self.u * angle.cos() + self.v * angle.sin() + } + fn tangent(self, angle: f32) -> Vec2 { + -self.u * angle.sin() + self.v * angle.cos() + } + fn phase(self, p: Vec2) -> Option { + let p = p - self.center; + let det = self.u.x * self.v.y - self.u.y * self.v.x; + // Near edge-on, an ellipse's inverse magnifies tiny pointer movements. + if det.abs() < self.u.length().max(self.v.length()).powi(2) * 0.15 { + return None; + } + let x = (p.x * self.v.y - p.y * self.v.x) / det; + let y = (p.y * self.u.x - p.x * self.u.y) / det; + (x.hypot(y) > 0.1).then(|| y.atan2(x)) + } +} + +#[derive(Clone, Copy)] +struct Geometry { + center: Pos2, + radius: f32, + rings: [Ring; 3], + xf: Xf, + bounds: vectorcraft_geom::Rect, +} + +impl Geometry { + fn new(app: &VectorcraftApp, d: &Dialog) -> Option { + let state = app.session.active()?; + let node = state.doc.node(*state.selection.objects.first()?)?; + let bounds = node.geometric_bounds()?; + let xf = Xf::new(app.canvas_rect?, app.view()?); + let options = revolve_options(&super::form::params(d)); + let center = xf.to_screen(options.rotation_origin(bounds)); + if !center.x.is_finite() || !center.y.is_finite() || !xf.rect.contains(center) { + return None; + } + let radius = (bounds.height().abs() * xf.zoom * 0.24).clamp(76.0, 116.0) as f32; + let (s, c) = (xf.rot as f32).sin_cos(); + let screen = |p: [f64; 3], scale: f32| vec2(c * p[0] as f32 - s * p[1] as f32, s * p[0] as f32 + c * p[1] as f32) * radius * scale; + let rings = std::array::from_fn(|i| { + let [u, v] = options.rotation_planes()[i]; + let scale = if i == 2 { 1.12 } else { 1.0 }; + Ring { center: Vec2::ZERO, u: screen(u, scale), v: screen(v, scale), depth: [u[2] as f32, v[2] as f32] } + }); + Some(Self { center, radius, rings, xf, bounds }) + } + + fn hit(self, p: Pos2) -> Option<(usize, f32)> { + let mut best = None; + let mut distance = 9.0; + for (axis, ring) in self.rings.iter().enumerate() { + for i in 0..SAMPLES { + let a = i as f32 * std::f32::consts::TAU / SAMPLES as f32; + let b = a + std::f32::consts::TAU / SAMPLES as f32; + let (start, end) = (self.center + ring.point(a), self.center + ring.point(b)); + let line = end - start; + let t = ((p - start).dot(line) / line.length_sq().max(1e-6)).clamp(0.0, 1.0); + let d = p.distance(start + line * t); + if d < distance { + distance = d; + best = Some((axis, a + (b - a) * t)); + } + } + } + best + } + + fn light_rings(self, options: Revolve) -> [Ring; 2] { + let (s, c) = (self.xf.rot as f32).sin_cos(); + let screen = |p: [f64; 3]| vec2(c * p[0] as f32 - s * p[1] as f32, s * p[0] as f32 + c * p[1] as f32) * self.radius * 1.4; + options.light_orbits().map(|[center, u, v]| Ring { center: screen(center), u: screen(u), v: screen(v), depth: [u[2] as f32, v[2] as f32] }) + } + + fn light_labels(self) -> [Rect; 2] { + [ + Rect::from_center_size(self.center + vec2(0.0, self.radius * 1.4 + 24.0), vec2(88.0, 26.0)), + Rect::from_center_size(self.center - vec2(self.radius * 1.4 + 32.0, 0.0), vec2(78.0, 26.0)), + ] + } + + fn light_hit(self, p: Pos2, options: Revolve) -> Option { + if let Some(axis) = self.light_labels().iter().position(|r| r.contains(p)) { + return Some(axis); + } + let mut best = None; + let mut distance = 9.0; + for (axis, ring) in self.light_rings(options).iter().enumerate() { + let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (-std::f32::consts::FRAC_PI_2, std::f32::consts::PI) }; + for i in 0..SAMPLES { + let a = start + i as f32 * span / SAMPLES as f32; + let (from, to) = (self.center + ring.point(a), self.center + ring.point(a + span / SAMPLES as f32)); + let line = to - from; + let t = ((p - from).dot(line) / line.length_sq().max(1e-6)).clamp(0.0, 1.0); + let d = p.distance(from + line * t); + if d < distance { + distance = d; + best = Some(axis); + } + } + } + best + } +} + +#[derive(Clone, Copy)] +enum Mode { + Ring { axis: usize, ring: Ring, phase: f32, degrees: f64 }, + Orbit, + Light, + LightRing { axis: usize }, +} + +#[derive(Clone)] +struct Drag { + mode: Mode, + geometry: Geometry, + last: Pos2, + origin: Pos2, + fields: serde_json::Map, +} + +fn wrap(n: f64, snap: bool) -> f64 { + let n = if snap { (n / 15.0).round() * 15.0 } else { n }; + (n + 180.0).rem_euclid(360.0) - 180.0 +} + +pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) { + let light_mode = lighting(d); + if !d.bool("preview") || (light_mode && !d.bool("shade")) { + clear(ctx); + return; + } + let Some(geometry) = Geometry::new(app, d) else { return }; + let geometry = ctx.data(|m| m.get_temp::(drag_id())).map_or(geometry, |drag| drag.geometry); + let mut rect = Rect::from_center_size(geometry.center, Vec2::splat(geometry.radius * 3.0 + 48.0)); + if light_mode { + for label in geometry.light_labels() { + rect = rect.union(label.expand(2.0)); + } + } + egui::Area::new(Id::new("revolve-gizmo-area")).order(egui::Order::Foreground).fixed_pos(rect.min).movable(false).show(ctx, |ui| { + ui.set_clip_rect(geometry.xf.rect); + let response = ui.allocate_rect(rect.intersect(geometry.xf.rect), Sense::drag()); + let options = revolve_options(&super::form::params(d)); + let light = light_position(geometry, options); + let hovered = response + .hovered() + .then(|| response.hover_pos()) + .flatten() + .and_then(|p| if light_mode { geometry.light_hit(p, options).map(|axis| (axis, 0.0)) } else { geometry.hit(p) }); + if response.drag_started() + && let Some(origin) = ui.input(|i| i.pointer.press_origin()) + { + let mode = if light_mode { + if origin.distance(light) <= 18.0 { + Mode::Light + } else if let Some(axis) = geometry.light_hit(origin, options) { + Mode::LightRing { axis } + } else { + Mode::Light + } + } else if origin.distance(geometry.center) <= 14.0 { + Mode::Orbit + } else if let Some((axis, phase)) = geometry.hit(origin) { + Mode::Ring { axis, ring: geometry.rings[axis], phase, degrees: 0.0 } + } else { + Mode::Orbit + }; + ui.ctx().memory_mut(|m| m.stop_text_input()); + ui.data_mut(|m| m.insert_temp(drag_id(), Drag { mode, geometry, last: origin, origin, fields: d.fields.clone() })); + } + let mut drag = ui.data(|m| m.get_temp::(drag_id())); + if let Some(current) = &mut drag { + if ui.input_mut(|i| i.consume_key(i.modifiers, egui::Key::Escape)) { + d.fields = current.fields.clone(); + d.fields.remove(super::form::PREVIEWED); + ui.data_mut(|m| m.remove::(drag_id())); + drag = None; + } else if let Some(p) = response.interact_pointer_pos() { + let snap = ui.input(|i| i.modifiers.shift); + match &mut current.mode { + Mode::Ring { axis, ring, phase, degrees } => { + let step = if let Some(next) = ring.phase(p - geometry.center) { + let step = (next - *phase + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI; + *phase = next; + step + } else { + let tangent = ring.tangent(*phase); + let step = (p - current.last).dot(tangent) / tangent.length_sq().max(144.0); + *phase += step; + step + }; + *degrees += f64::from(step.to_degrees()); + if let Some(key) = KEYS.get(*axis) { + let start = current.fields.get(*key).and_then(|v| v.as_f64()).unwrap_or(0.0); + d.fields.insert((*key).into(), json!(wrap(start + *degrees, snap))); + } + } + Mode::Orbit => { + let delta = geometry.xf.delta_to_doc(p - current.origin) * geometry.xf.zoom; + for (key, delta) in [("rotationX", delta.y * 0.5), ("rotationY", delta.x * 0.5)] { + let start = current.fields.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0); + d.fields.insert(key.into(), json!(wrap(start + delta, snap))); + } + } + Mode::Light => { + let start = revolve_options(&serde_json::Value::Object(current.fields.clone())); + let projected = light_position(geometry, start) + (p - current.origin); + let point = geometry.xf.delta_to_doc(projected - geometry.center) * (geometry.xf.zoom / f64::from(geometry.radius * 1.4)); + let [azimuth, elevation] = start.light_at_on_side(Point::new(point.x, point.y)); + d.fields.insert("lightAzimuth".into(), json!(wrap(azimuth, snap))); + d.fields.insert( + "lightElevation".into(), + json!((if snap { (elevation / 15.0).round() * 15.0 } else { elevation }).clamp(-90.0, 90.0)), + ); + } + Mode::LightRing { axis } => { + let delta = geometry.xf.delta_to_doc(p - current.origin) * geometry.xf.zoom; + let start = revolve_options(&serde_json::Value::Object(current.fields.clone())); + if *axis == 0 { + d.fields.insert("lightAzimuth".into(), json!(wrap(start.light_azimuth + delta.x * 0.6, snap))); + } else { + let elevation = start.light_elevation - delta.y * 0.6; + d.fields.insert( + "lightElevation".into(), + json!((if snap { (elevation / 15.0).round() * 15.0 } else { elevation }).clamp(-90.0, 90.0)), + ); + } + } + } + current.last = p; + ui.data_mut(|m| m.insert_temp(drag_id(), current.clone())); + } + } + if response.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + ui.data_mut(|m| m.remove::(drag_id())); + } + if response.hovered() || drag.is_some() { + ui.ctx().set_cursor_icon(if drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + let active = match drag.as_ref().map(|d| d.mode) { + Some(Mode::Ring { axis, .. }) => Some(axis), + Some(Mode::LightRing { axis }) => Some(axis), + _ => hovered.map(|h| h.0), + }; + paint(ui, d, Geometry::new(app, d).unwrap_or(geometry), active, drag.as_ref()); + response.on_hover_text(hint(d)); + }); +} + +fn light_position(g: Geometry, options: Revolve) -> Pos2 { + let [x, y, _] = options.light_direction(); + let (s, c) = (g.xf.rot as f32).sin_cos(); + g.center + vec2(c * x as f32 - s * y as f32, s * x as f32 + c * y as f32) * g.radius * 1.4 +} + +fn paint(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option, drag: Option<&Drag>) { + if lighting(d) { + paint_light(ui, d, g, active, drag); + return; + } + let t = Tokens::get(ui.ctx()); + let painter = ui.painter(); + let colors = [t.axis_x, t.axis_y, t.axis_z]; + for (axis, ring) in g.rings.iter().enumerate() { + let color = if active == Some(axis) { t.warning } else { colors[axis] }; + for i in 0..SAMPLES { + let angle = i as f32 * std::f32::consts::TAU / SAMPLES as f32; + let behind = ring.depth[0] * angle.cos() + ring.depth[1] * angle.sin() < -0.05; + if behind && i % 3 == 0 { + continue; + } + let points = [g.center + ring.point(angle), g.center + ring.point(angle + std::f32::consts::TAU / SAMPLES as f32)]; + painter.line_segment(points, Stroke::new(4.0, t.input.gamma_multiply(0.55))); + painter.line_segment( + points, + Stroke::new(if active == Some(axis) { 2.5 } else { 1.75 }, color.gamma_multiply(if behind { 0.55 } else { 1.0 })), + ); + } + // Letters make the axes distinguishable without relying on colour alone. + let phase = match axis { + 0 => 0.75, + 1 => 2.25, + _ => 4.7, + }; + let p = g.center + ring.point(phase); + painter.circle_filled(p, 9.0, t.panel); + painter.text(p, Align2::CENTER_CENTER, ["X", "Y", "Z"][axis], FontId::proportional(11.0), color); + } + painter.circle(g.center, 8.0, t.panel.gamma_multiply(0.8), Stroke::new(1.5, t.text_dim)); + painter.line_segment([g.center - vec2(4.0, 0.0), g.center + vec2(4.0, 0.0)], Stroke::new(1.0, t.text_dim)); + painter.line_segment([g.center - vec2(0.0, 4.0), g.center + vec2(0.0, 4.0)], Stroke::new(1.0, t.text_dim)); + let options = revolve_options(&super::form::params(d)); + if d.bool("__showAxis") { + painter.line_segment(options.projected_axis(g.bounds).map(|p| g.xf.to_screen(p)), Stroke::new(1.0, t.warning)); + } + if let Some(Drag { mode: Mode::Ring { axis, .. }, .. }) = drag + && let Some(key) = KEYS.get(*axis) + { + let text = format!("{} {:.1}°", ["X", "Y", "Z"].get(*axis).unwrap_or(&""), d.f64(key, 0.0)); + let pos = g.center + vec2(0.0, g.radius * 1.12 + 18.0); + let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.text_strong); + painter.rect_filled(Rect::from_center_size(pos, galley.size() + vec2(14.0, 8.0)), 4.0, t.panel); + painter.galley(pos - galley.size() * 0.5, galley, t.text_strong); + } +} + +fn paint_light(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option, drag: Option<&Drag>) { + let t = Tokens::get(ui.ctx()); + let painter = ui.painter(); + let options = revolve_options(&super::form::params(d)); + let radius = g.radius * 1.4; + painter.circle_stroke(g.center, radius, Stroke::new(1.0, t.text_dim.gamma_multiply(0.3))); + let labels = g.light_labels(); + for (axis, ring) in g.light_rings(options).iter().enumerate() { + let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (-std::f32::consts::FRAC_PI_2, std::f32::consts::PI) }; + for i in 0..SAMPLES { + let a = start + i as f32 * span / SAMPLES as f32; + let behind = ring.depth[0] * a.cos() + ring.depth[1] * a.sin() < -0.05; + if behind && i % 3 == 0 { + continue; + } + let points = [g.center + ring.point(a), g.center + ring.point(a + span / SAMPLES as f32)]; + painter.line_segment(points, Stroke::new(4.0, t.panel.gamma_multiply(0.4))); + painter.line_segment( + points, + Stroke::new(if active == Some(axis) { 2.5 } else { 1.5 }, t.warning.gamma_multiply(if behind { 0.45 } else { 0.85 })), + ); + } + let label = labels[axis]; + painter.rect_filled(label, 4.0, if active == Some(axis) { t.hover } else { t.panel }); + let text = if axis == 0 { tl!("Direction") } else { tl!("Elevation") }; + painter.text(label.center(), Align2::CENTER_CENTER, text, FontId::proportional(11.0), t.warning); + } + let p = light_position(g, options); + let delta = g.center - p; + if delta.length() > 24.0 { + let direction = delta.normalized(); + let normal = vec2(-direction.y, direction.x); + let tip = g.center - direction * 12.0; + painter.line_segment([p, tip], Stroke::new(1.25, t.warning.gamma_multiply(0.6))); + painter.line_segment([tip, tip - direction * 8.0 + normal * 4.0], Stroke::new(1.25, t.warning)); + painter.line_segment([tip, tip - direction * 8.0 - normal * 4.0], Stroke::new(1.25, t.warning)); + } + painter.circle(g.center, 4.0, t.panel, Stroke::new(1.0, t.text_dim)); + // An original sun handle. A back light is hollow; the guide's back half is dashed. + let front = options.light_direction()[2] >= 0.0; + painter.circle(p, 9.0, if front { t.warning } else { t.panel }, Stroke::new(2.0, t.warning)); + for i in 0..8 { + let angle = i as f32 * std::f32::consts::TAU / 8.0; + let direction = vec2(angle.cos(), angle.sin()); + painter.line_segment([p + direction * 13.0, p + direction * 17.0], Stroke::new(1.5, t.warning)); + } + let name = crate::i18n::tr_ctx(crate::i18n::current(), "lighting", "Light"); + let galley = painter.layout_no_wrap(name.to_string(), FontId::proportional(11.0), t.warning); + let name_pos = p - vec2(galley.size().x * 0.5, 25.0 + galley.size().y); + painter.rect_filled(Rect::from_min_size(name_pos - vec2(4.0, 2.0), galley.size() + vec2(8.0, 4.0)), 3.0, t.panel); + painter.galley(name_pos, galley, t.warning); + if drag.is_some() { + let text = format!("{} {:.1}° {} {:.1}°", tl!("Direction"), d.f64("lightAzimuth", -45.0), tl!("Elevation"), d.f64("lightElevation", 45.0)); + let galley = painter.layout_no_wrap(text, FontId::proportional(11.5), t.text_strong); + let pos = g.center + vec2(0.0, radius + 56.0); + painter.rect_filled(Rect::from_center_size(pos, galley.size() + vec2(12.0, 6.0)), 4.0, t.panel); + painter.galley(pos - galley.size() * 0.5, galley, t.text_strong); + } +} + +#[cfg(test)] +#[path = "tests_revolve_gizmo.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 14358684d..f9534577b 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -228,6 +228,26 @@ fn effect_dialog_applies_to_its_appearance_item() { assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty()); } +#[test] +fn revolve_dialog_preview_cancel_confirm_and_undo() { + let mut app = app(); + app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + app.run("effect.dialog", json!({"effect":"threeD.revolve","item":null})).unwrap(); + frame(&mut app, Default::default()); + assert!(app.session.in_interaction()); + cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("effect.dialog", json!({"effect":"threeD.revolve","item":null})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("angle".into(), json!(180)); + frame(&mut app, Default::default()); + confirm(&mut app).unwrap(); + assert_ne!(app.session.doc().unwrap().doc, before); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); +} + #[test] fn dialogs_are_as_tall_as_their_content() { // The save prompt, shown after a taller dialog, is as tall as its text and buttons: no empty diff --git a/crates/ui-egui/src/dialogs/tests_revolve.rs b/crates/ui-egui/src/dialogs/tests_revolve.rs new file mode 100644 index 000000000..554a4fe5b --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_revolve.rs @@ -0,0 +1,201 @@ +//! Real pointer interaction with Revolve's compact controls. +use super::*; +use crate::{dialogs, theme}; +use egui::{Event, PointerButton, Rect, Vec2, pos2}; +use vectorcraft_engine::Session; + +struct Harness { + app: VectorcraftApp, + ctx: egui::Context, + screen: Vec2, + texts: Vec<(String, Rect)>, +} + +impl Harness { + fn new(screen: Vec2) -> Self { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":300,"height":300})).unwrap(); + app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap(); + app.run("paint.setFill", json!({"color":"#338ad6"})).unwrap(); + app.run("effect.dialog", json!({"effect":REVOLVE,"item":null})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + Self { app, ctx, screen, texts: vec![] } + } + + fn frame(&mut self, events: Vec) { + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, self.screen)), events, ..Default::default() }; + let mut out = self.ctx.run_ui(input, |ui| dialogs::show(&mut self.app, ui.ctx())); + out.textures_delta.clear(); + fn collect(shape: &egui::Shape, clip: Rect, texts: &mut Vec<(String, Rect)>) { + match shape { + egui::Shape::Text(t) if clip.contains_rect(t.visual_bounding_rect()) => { + texts.push((t.galley.text().to_string(), t.visual_bounding_rect())) + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, clip, texts)), + _ => {} + } + } + self.texts.clear(); + out.shapes.iter().for_each(|s| collect(&s.shape, s.clip_rect, &mut self.texts)); + } + + fn settle(&mut self) { + for _ in 0..5 { + self.frame(vec![]); + } + } + + fn rect(&self, text: &str) -> Rect { + self.texts.iter().find(|(s, _)| s == text).map(|(_, r)| *r).unwrap_or_else(|| panic!("missing text {text:?}")) + } + + fn response(&self, id: impl std::hash::Hash + std::fmt::Debug) -> Rect { + self.ctx.read_response(Id::new(id)).unwrap().rect + } + + fn click(&mut self, p: Pos2) { + self.frame(vec![Event::PointerMoved(p), button(p, true)]); + self.frame(vec![button(p, false)]); + self.settle(); + } + + fn drag(&mut self, p: Pos2, delta: Vec2) { + self.frame(vec![Event::PointerMoved(p), button(p, true)]); + for i in 1..=5 { + self.frame(vec![Event::PointerMoved(p + delta * (i as f32 / 5.0))]); + } + self.frame(vec![button(p + delta, false)]); + self.settle(); + } + + fn value(&self, key: &str) -> f64 { + self.app.ui.dialog.as_ref().unwrap().f64(key, 0.0) + } +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +#[test] +fn values_labels_sliders_and_reset_dots_change_the_live_effect_in_one_undo_step() { + let mut h = Harness::new(vec2(1280.0, 900.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + assert!(!h.rect("Advanced").is_negative(), "Advanced is visible without scrolling in a normal window"); + let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); + assert!(panel.width() < 420.0 && panel.height() < 540.0, "compact controls: {panel:?}"); + let label = h.rect("Rotation X"); + h.drag(pos2(label.left() + LABEL_W + 6.0 + VALUE_W / 2.0, label.center().y), vec2(40.0, 0.0)); + assert!(h.value("rotationX") > 0.0, "the value itself drags"); + let label = h.rect("Rotation Y"); + h.drag(label.center(), vec2(40.0, 0.0)); + assert!(h.value("rotationY") > 0.0, "the label scrubs"); + let label = h.rect("Revolve angle"); + h.click(pos2(label.left() + LABEL_W + VALUE_W + 24.0, label.center().y)); + assert!(h.value("angle") < 180.0, "the slider changes the sweep"); + h.click(h.response(("revolve-reset", "rotationX")).center()); + assert_eq!(h.value("rotationX"), 0.0); + assert!(h.value("angle") < 180.0, "a dot resets only its own setting"); + let node = h.app.session.doc().unwrap().doc.node(h.app.session.doc().unwrap().selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["angle"], json!(h.value("angle"))); + h.click(h.response("revolve-reset-all").center()); + let defaults = vectorcraft_effects::default_params(REVOLVE).unwrap(); + let dialog = h.app.ui.dialog.as_ref().unwrap(); + for (key, value) in defaults.as_object().unwrap() { + assert_eq!(dialog.fields.get(key), Some(value), "reset all: {key}"); + } + dialogs::confirm(&mut h.app).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + +#[test] +fn preview_off_changes_fields_without_changing_the_document() { + let mut h = Harness::new(vec2(1280.0, 900.0)); + h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + h.drag(h.rect("Rotation Y").center(), vec2(40.0, 0.0)); + assert!(h.value("rotationY") > 0.0); + assert_eq!(h.app.session.doc().unwrap().doc, before); + dialogs::cancel(&mut h.app); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + +#[test] +fn command_driven_fields_preview_and_small_windows_keep_the_buttons_reachable() { + let mut h = Harness::new(vec2(640.0, 480.0)); + h.settle(); + let dialog = h.app.ui.dialog.as_mut().unwrap(); + dialog.fields.insert("angle".into(), json!(180)); + h.settle(); + let state = h.app.session.doc().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["angle"], json!(180)); + let screen = Rect::from_min_size(Pos2::ZERO, h.screen); + assert!(screen.contains_rect(h.rect("OK")) && screen.contains_rect(h.rect("Cancel"))); + dialogs::cancel(&mut h.app); + assert!(!h.app.session.in_interaction()); +} + +#[test] +fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() { + let mut h = Harness::new(vec2(1280.0, 900.0)); + h.settle(); + h.click(h.rect("Lighting").center()); + assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "light"); + for label in ["Shading", "Direction", "Elevation", "Intensity", "Ambient"] { + assert!(Rect::from_min_size(Pos2::ZERO, h.screen).contains_rect(h.rect(label))); + } + let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); + assert!(panel.width() < 420.0 && panel.height() < 460.0, "compact lighting controls: {panel:?}"); + h.drag(h.rect("Direction").center(), vec2(24.0, 0.0)); + assert_ne!(h.value("lightAzimuth"), -45.0); + h.click(h.response(("revolve-reset", "lightAzimuth")).center()); + assert_eq!(h.value("lightAzimuth"), -45.0); + h.click(h.rect("Rotation").center()); + assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "rotation"); + assert!(!h.rect("Rotation X").is_negative()); +} + +#[test] +fn advanced_visibility_option_is_saved_resettable_and_leaves_preview_geometry_complete() { + let mut h = Harness::new(vec2(1280.0, 1000.0)); + h.settle(); + h.click(h.rect("Advanced").center()); + let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); + h.frame(vec![ + Event::PointerMoved(panel.center()), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -220.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }, + ]); + h.settle(); + let before = vectorcraft_effects::revolve_art({ + let state = h.app.session.doc().unwrap(); + state.doc.node(state.selection.objects[0]).unwrap() + }) + .unwrap(); + let checkbox = h.rect("Keep visible surfaces only when expanding"); + h.click(checkbox.center()); + assert!(!h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly")); + let state = h.app.session.doc().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["expandVisibleOnly"], json!(false)); + assert_eq!(vectorcraft_effects::revolve_art(node), Some(before)); + h.click(h.response(("revolve-reset", "expandVisibleOnly")).center()); + assert!(h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly")); + h.click(h.rect("Keep visible surfaces only when expanding").center()); + h.click(h.response("revolve-reset-all").center()); + assert!(h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly")); + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let loaded = vectorcraft_format::load(&vectorcraft_format::save(&state.doc, false)).unwrap(); + assert_eq!(loaded.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true)); +} diff --git a/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs new file mode 100644 index 000000000..02c3fa3e4 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs @@ -0,0 +1,250 @@ +//! Pointer gestures exercise the real artboard overlay and command preview, including rollback. +use super::*; +use crate::{dialogs, theme}; +use egui::{Event, Modifiers, PointerButton}; +use vectorcraft_engine::Session; + +struct Harness { + app: VectorcraftApp, + ctx: egui::Context, +} + +impl Harness { + fn new() -> Self { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":300,"height":300})).unwrap(); + app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap(); + app.run("paint.setFill", json!({"color":"#338ad6"})).unwrap(); + app.run("effect.dialog", json!({"effect":REVOLVE,"item":null})).unwrap(); + app.canvas_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0))); + let v = app.view_mut().unwrap(); + v.center = Point::new(270.0, 150.0); + v.zoom = 2.0; + v.fitted = true; + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let mut h = Self { app, ctx }; + h.settle(); + h + } + + fn frame(&mut self, events: Vec, modifiers: Modifiers) { + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0))), events, ..Default::default() }; + let mut out = self.ctx.run_ui(input, |ui| { + crate::shortcuts::handle(&mut self.app, ui.ctx()); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::show(&mut self.app, ui)); + dialogs::show(&mut self.app, ui.ctx()); + }); + out.textures_delta.clear(); + } + + fn settle(&mut self) { + for _ in 0..5 { + self.frame(vec![], Modifiers::NONE); + } + } + + fn geometry(&self) -> Geometry { + Geometry::new(&self.app, self.app.ui.dialog.as_ref().unwrap()).unwrap() + } + + fn value(&self, key: &str) -> f64 { + self.app.ui.dialog.as_ref().unwrap().f64(key, 0.0) + } + + fn start(&mut self, p: Pos2, modifiers: Modifiers) { + self.frame(vec![Event::PointerMoved(p)], modifiers); + self.frame(vec![button(p, true, modifiers)], modifiers); + } + + fn release(&mut self, p: Pos2, modifiers: Modifiers) { + self.frame(vec![button(p, false, modifiers)], modifiers); + self.settle(); + } + + fn ring_drag(&mut self, axis: usize, modifiers: Modifiers) { + let g = self.geometry(); + let ring = g.rings[axis]; + // Choose a visible point belonging only to this ring, away from crossings. + let phase = (0..SAMPLES) + .map(|i| i as f32 * std::f32::consts::TAU / SAMPLES as f32) + .find(|a| g.hit(g.center + ring.point(*a)).is_some_and(|h| h.0 == axis) && ring.tangent(*a).length() > 40.0) + .unwrap(); + let from = g.center + ring.point(phase); + self.start(from, modifiers); + let mut p = from; + for i in 1..=8 { + p = g.center + ring.point(phase + i as f32 * 0.045); + self.frame(vec![Event::PointerMoved(p)], modifiers); + } + self.release(p, modifiers); + } +} + +fn button(pos: Pos2, pressed: bool, modifiers: Modifiers) -> Event { + Event::PointerButton { pos, pressed, button: PointerButton::Primary, modifiers } +} +fn escape() -> Event { + Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } +} + +#[test] +fn each_ring_changes_only_its_rotation_field_and_cancel_restores_the_source() { + for (axis, key) in KEYS.iter().enumerate() { + let mut h = Harness::new(); + let before = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone(); + let original = KEYS.map(|key| h.value(key)); + h.ring_drag(axis, Modifiers::NONE); + for (k, key) in KEYS.iter().enumerate() { + if k == axis { + assert!(h.value(key) > original[k] + 10.0, "axis {axis}, {}", h.value(key)); + } else { + assert_eq!(h.value(key), original[k]); + } + } + let state = h.app.session.active().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.path_data(), before.node(node.id).unwrap().path_data(), "ring drags must not move source anchors"); + assert_eq!(node.appearance.effects[0].params[*key], json!(h.value(key))); + dialogs::cancel(&mut h.app); + assert_eq!(h.app.session.doc().unwrap().doc, before); + } +} + +#[test] +fn ring_drag_snaps_and_confirm_is_one_undo_step() { + let mut h = Harness::new(); + let before = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone(); + h.ring_drag(2, Modifiers::SHIFT); + assert!(h.value("rotationZ") > 0.0); + assert_eq!(h.value("rotationZ") % 15.0, 0.0); + dialogs::confirm(&mut h.app).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + +#[test] +fn free_orbit_and_light_drag_update_the_canvas_and_escape_restores_only_the_gesture() { + let mut h = Harness::new(); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + let center = h.geometry().center; + h.start(center, Modifiers::NONE); + let p = center + vec2(50.0, 30.0); + h.frame(vec![Event::PointerMoved(p)], Modifiers::NONE); + assert_ne!(h.value("rotationX"), before["rotationX"].as_f64().unwrap()); + assert_ne!(h.value("rotationY"), before["rotationY"].as_f64().unwrap()); + h.frame(vec![escape()], Modifiers::NONE); + h.release(p, Modifiers::NONE); + assert!(h.app.ui.dialog.is_some()); + assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); + let state = h.app.session.active().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["rotationX"], before["rotationX"]); + assert_eq!(node.appearance.effects[0].params["rotationY"], before["rotationY"]); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light")); + h.settle(); + let light = light_position(h.geometry(), revolve_options(&before)); + h.start(light, Modifiers::NONE); + let p = light + vec2(20.0, 25.0); + h.frame(vec![Event::PointerMoved(p)], Modifiers::NONE); + h.release(p, Modifiers::NONE); + assert_ne!(h.value("lightAzimuth"), -45.0); + assert_ne!(h.value("lightElevation"), 45.0); + h.frame(vec![escape()], Modifiers::NONE); + assert!(h.app.ui.dialog.is_none() && !h.app.session.in_interaction()); +} + +#[test] +fn preview_off_has_no_gizmo_and_edge_on_rings_remain_finite() { + let mut h = Harness::new(); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("rotationX".into(), json!(0.0)); + d.fields.insert("rotationY".into(), json!(0.0)); + d.fields.insert("rotationZ".into(), json!(0.0)); + h.settle(); + h.ring_drag(0, Modifiers::NONE); + assert!(h.value("rotationX").is_finite() && h.value("rotationX").abs() > 1.0); + h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false)); + h.settle(); + assert!(!h.app.session.in_interaction()); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + h.ring_drag(2, Modifiers::NONE); + assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); +} + +#[test] +fn light_rings_adjust_only_their_angle_and_snap_without_rotating_the_object() { + for (axis, key) in ["lightAzimuth", "lightElevation"].iter().enumerate() { + let mut h = Harness::new(); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light")); + h.settle(); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + let from = h.geometry().light_labels()[axis].center(); + h.start(from, Modifiers::SHIFT); + let delta = if axis == 0 { vec2(68.0, 0.0) } else { vec2(0.0, -43.0) }; + h.frame(vec![Event::PointerMoved(from + delta)], Modifiers::SHIFT); + h.release(from + delta, Modifiers::SHIFT); + assert_ne!(h.value(key), before[*key].as_f64().unwrap()); + assert_eq!(h.value(key) % 15.0, 0.0); + for unchanged in ["rotationX", "rotationY", "rotationZ", "lightIntensity", "ambient"] { + assert_eq!(h.value(unchanged), before[unchanged].as_f64().unwrap()); + } + let other = if axis == 0 { "lightElevation" } else { "lightAzimuth" }; + assert_eq!(h.value(other), before[other].as_f64().unwrap()); + let state = h.app.session.active().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params[*key], json!(h.value(key))); + } +} + +#[test] +fn light_handle_preserves_the_back_side_and_escape_cancel_and_undo_restore_changes() { + let mut h = Harness::new(); + let original = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone(); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("__gizmo".into(), json!("light")); + d.fields.insert("lightAzimuth".into(), json!(135.0)); + h.settle(); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + let light = light_position(h.geometry(), revolve_options(&before)); + h.start(light, Modifiers::NONE); + let to = light + vec2(-15.0, 12.0); + h.frame(vec![Event::PointerMoved(to)], Modifiers::NONE); + assert!(revolve_options(&super::super::form::params(h.app.ui.dialog.as_ref().unwrap())).light_direction()[2] < 0.0); + assert_ne!(h.value("lightElevation"), 45.0); + h.frame(vec![escape()], Modifiers::NONE); + h.release(to, Modifiers::NONE); + assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); + let state = h.app.session.active().unwrap(); + assert_eq!(state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["lightAzimuth"], before["lightAzimuth"]); + // A second gesture is kept by OK, and the whole dialog is one undo step. + let from = h.geometry().light_labels()[0].center(); + h.start(from, Modifiers::NONE); + h.frame(vec![Event::PointerMoved(from + vec2(35.0, 0.0))], Modifiers::NONE); + h.release(from + vec2(35.0, 0.0), Modifiers::NONE); + dialogs::confirm(&mut h.app).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, original); +} + +#[test] +fn light_elevation_is_bounded_and_shading_off_disables_the_light_gizmo() { + let mut h = Harness::new(); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light")); + h.settle(); + let from = h.geometry().light_labels()[1].center(); + h.start(from, Modifiers::NONE); + h.frame(vec![Event::PointerMoved(from - vec2(0.0, 400.0))], Modifiers::NONE); + h.release(from - vec2(0.0, 400.0), Modifiers::NONE); + assert_eq!(h.value("lightElevation"), 90.0); + h.app.ui.dialog.as_mut().unwrap().fields.insert("shade".into(), json!(false)); + h.settle(); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + let from = h.geometry().light_labels()[0].center(); + h.start(from, Modifiers::NONE); + h.frame(vec![Event::PointerMoved(from + vec2(40.0, 0.0))], Modifiers::NONE); + h.release(from + vec2(40.0, 0.0), Modifiers::NONE); + assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bbcc82585..f02a85f82 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -694,3 +694,32 @@ Ocean Ripple… Vlnky na hladině… Rename Data Set Přejmenovat sadu dat Rename Variable Přejmenovat proměnnou + +# Interactive Revolve controls. + Lighting Osvětlení + Revolve angle Úhel otočení + Axis side Strana osy + Rotation X Otočení X + Rotation Y Otočení Y + Rotation Z Otočení Z + Shading Stínování + Direction Směr + Elevation Výška světla + Intensity Intenzita + Ambient Okolní osvětlení + Segments Segmenty + Show axis Zobrazit osu + Reset all Obnovit vše + Reset to default Obnovit výchozí hodnotu + Reset all Revolve settings to their defaults. Obnovit výchozí hodnoty všech nastavení rotačního tělesa. + Drag values or labels. Double-click a value to type. Upravte hodnoty přetažením čísel nebo popisků. Dvojím kliknutím na hodnotu ji zadáte. + Drag rings to rotate. Shift snaps to 15°. Otáčejte přetažením prstenců. Shift nastaví kroky po 15°. + Drag the light, or its rings. Shift snaps to 15°. Přetáhněte světlo nebo jeho prstence. Shift nastaví kroky po 15°. +lighting Light Světlo + Keep visible surfaces only when expanding Při rozšíření ponechat pouze viditelné povrchy + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Při rozšíření vzhledu oříznout zakrytou geometrii. Živý náhled zachová celý povrch. Průhledné výplně a povrchy překračující limity výpočtu viditelnosti zachovají všechny plochy. + Left Vlevo + Right Vpravo + Offset Odsazení + Rotation Otočení + Cancel Zrušit diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d52cf1ef5..4f5b09b33 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4037,3 +4037,29 @@ axis Both Ambos Define a variable first Defina primero una variable Text Texto Visibility Visibilidad + +# Revolve profile guidance. + +# Interactive Revolve controls. + Lighting Iluminación + Revolve angle Ángulo de revolución + Axis side Lado del eje + Rotation X Rotación X + Rotation Y Rotación Y + Rotation Z Rotación Z + Shading Sombreado + Direction Dirección + Elevation Elevación + Intensity Intensidad + Ambient Luz ambiental + Segments Segmentos + Show axis Mostrar eje + Reset all Restablecer todo + Reset to default Restablecer valor predeterminado + Reset all Revolve settings to their defaults. Restablece todos los ajustes de revolución a sus valores predeterminados. + Drag values or labels. Double-click a value to type. Arrastra los valores o las etiquetas. Haz doble clic en un valor para escribir. + Drag rings to rotate. Shift snaps to 15°. Arrastra los anillos para girar. Shift ajusta a pasos de 15°. + Drag the light, or its rings. Shift snaps to 15°. Arrastra la luz o sus anillos. Shift ajusta a pasos de 15°. +lighting Light Luz + Keep visible surfaces only when expanding Conservar solo superficies visibles al expandir + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Recorta la geometría oculta al expandir la apariencia. La vista previa conserva toda la superficie. Las pinturas transparentes y las superficies que superan los límites de visibilidad conservan todas las caras. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 76796bd1f..669e125cd 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4049,3 +4049,27 @@ effect Soft Doux Define a variable first Définissez d’abord une variable Text Texte Visibility Visibilité + +# Interactive Revolve controls. + Lighting Éclairage + Revolve angle Angle de révolution + Axis side Côté de l’axe + Rotation X Rotation X + Rotation Y Rotation Y + Rotation Z Rotation Z + Shading Ombrage + Direction Direction + Elevation Élévation + Intensity Intensité + Ambient Lumière ambiante + Segments Segments + Show axis Afficher l’axe + Reset all Tout réinitialiser + Reset to default Valeur par défaut + Reset all Revolve settings to their defaults. Rétablir les valeurs par défaut de tous les réglages de révolution. + Drag values or labels. Double-click a value to type. Faites glisser les valeurs ou les libellés. Double-cliquez sur une valeur pour la saisir. + Drag rings to rotate. Shift snaps to 15°. Faites glisser les anneaux pour tourner. Maj contraint par pas de 15°. + Drag the light, or its rings. Shift snaps to 15°. Faites glisser la lumière ou ses anneaux. Maj contraint par pas de 15°. + Keep visible surfaces only when expanding Conserver uniquement les surfaces visibles lors de la décomposition + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Découpe la géométrie masquée lors de la décomposition de l’aspect. L’aperçu conserve toute la surface. Les peintures transparentes et les surfaces dépassant les limites de visibilité conservent toutes les faces. +lighting Light Lumière diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a9c328e96..11bb0b561 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4039,3 +4039,29 @@ effect Regular Normale Define a variable first Definisci prima una variabile Text Testo Visibility Visibilità + +# Revolve profile guidance. + +# Interactive Revolve controls. + Lighting Illuminazione + Revolve angle Angolo di rivoluzione + Axis side Lato dell’asse + Rotation X Rotazione X + Rotation Y Rotazione Y + Rotation Z Rotazione Z + Shading Ombreggiatura + Direction Direzione + Elevation Elevazione + Intensity Intensità + Ambient Luce ambientale + Segments Segmenti + Show axis Mostra asse + Reset all Ripristina tutto + Reset to default Ripristina valore predefinito + Reset all Revolve settings to their defaults. Ripristina tutte le impostazioni di rivoluzione ai valori predefiniti. + Drag values or labels. Double-click a value to type. Trascina i valori o le etichette. Fai doppio clic su un valore per digitarlo. + Drag rings to rotate. Shift snaps to 15°. Trascina gli anelli per ruotare. Shift usa incrementi di 15°. + Drag the light, or its rings. Shift snaps to 15°. Trascina la luce o i suoi anelli. Shift usa incrementi di 15°. +lighting Light Luce + Keep visible surfaces only when expanding Mantieni solo le superfici visibili durante l’espansione + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Ritaglia la geometria coperta durante Espandi aspetto. L’anteprima mantiene la superficie completa. Le tinte trasparenti e le superfici che superano i limiti di visibilità mantengono tutte le facce. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6c13d3aee..a68c40578 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4032,3 +4032,29 @@ effect Vertical 垂直 Define a variable first 先に変数を定義してください Text テキスト Visibility 表示状態 + +# Revolve profile guidance. + +# Interactive Revolve controls. + Lighting 照明 + Revolve angle 回転体の角度 + Axis side 軸の位置 + Rotation X X 軸回転 + Rotation Y Y 軸回転 + Rotation Z Z 軸回転 + Shading 陰影 + Direction 方向 + Elevation 仰角 + Intensity 強度 + Ambient 環境光 + Segments 分割数 + Show axis 軸を表示 + Reset all すべてリセット + Reset to default 初期値に戻す + Reset all Revolve settings to their defaults. 回転体のすべての設定を初期値に戻します。 + Drag values or labels. Double-click a value to type. 数値やラベルをドラッグして調整。数値をダブルクリックすると入力できます。 + Drag rings to rotate. Shift snaps to 15°. リングをドラッグして回転。Shiftで15°刻みに調整。 + Drag the light, or its rings. Shift snaps to 15°. ライトやリングをドラッグ。Shiftで15°刻みに調整。 +lighting Light ライト + Keep visible surfaces only when expanding アピアランス分割時に見える面だけを保持 + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. アピアランスの分割時に隠れた部分を除去します。プレビューでは面全体を保持します。透明なペイントや可視性処理の制限を超える面は、すべての面を保持します。 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b1526a884..ec9b4d38c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2754,3 +2754,30 @@ Ocean Ripple… Ondulação oceânica… Rename Data Set Renomear conjunto de dados Rename Variable Renomear variável + +# Interactive Revolve controls. + Lighting Iluminação + Revolve angle Ângulo de giro + Axis side Lado do eixo + Rotation X Rotação X + Rotation Y Rotação Y + Rotation Z Rotação Z + Shading Sombreamento + Direction Direção + Elevation Elevação + Intensity Intensidade + Ambient Luz ambiente + Segments Segmentos + Show axis Mostrar eixo + Reset all Redefinir tudo + Reset to default Restaurar padrão + Reset all Revolve settings to their defaults. Restaurar os valores padrão de todas as configurações de revolução. + Drag values or labels. Double-click a value to type. Arraste os valores ou rótulos. Clique duas vezes em um valor para digitá-lo. + Drag rings to rotate. Shift snaps to 15°. Arraste os anéis para girar. Shift ajusta em incrementos de 15°. + Drag the light, or its rings. Shift snaps to 15°. Arraste a luz ou seus anéis. Shift ajusta em incrementos de 15°. +lighting Light Luz + Keep visible surfaces only when expanding Manter apenas as superfícies visíveis ao expandir + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Recortar a geometria coberta ao expandir a aparência. A visualização ao vivo mantém toda a superfície. Pinturas transparentes e superfícies que excedem os limites de visibilidade mantêm todas as faces. + Left Esquerda + Right Direita + Offset Deslocamento diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3fd2cfe8d..a1acb7957 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4035,3 +4035,29 @@ axis Both Оба Define a variable first Сначала определите переменную Text Текст Visibility Видимость + +# Revolve profile guidance. + +# Interactive Revolve controls. + Lighting Освещение + Revolve angle Угол вращения профиля + Axis side Сторона оси + Rotation X Поворот X + Rotation Y Поворот Y + Rotation Z Поворот Z + Shading Затенение + Direction Направление + Elevation Высота + Intensity Интенсивность + Ambient Фоновое освещение + Segments Сегменты + Show axis Показать ось + Reset all Сбросить всё + Reset to default Вернуть значение по умолчанию + Reset all Revolve settings to their defaults. Возвращает все настройки вращения профиля к значениям по умолчанию. + Drag values or labels. Double-click a value to type. Перетаскивайте значения или подписи. Для ввода дважды щёлкните значение. + Drag rings to rotate. Shift snaps to 15°. Перетаскивайте кольца для вращения. Shift задаёт шаг 15°. + Drag the light, or its rings. Shift snaps to 15°. Перетаскивайте источник света или кольца. Shift задаёт шаг 15°. +lighting Light Свет + Keep visible surfaces only when expanding При разборе сохранять только видимые поверхности + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Удаляет скрытую геометрию при разборе оформления. Предпросмотр сохраняет всю поверхность. Прозрачные краски и поверхности, превышающие ограничения видимости, сохраняют все грани. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8140c543d..28241c6d6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4020,3 +4020,27 @@ axis Both Обидві Define a variable first Спочатку визначте змінну Text Текст Visibility Видимість + +# Interactive Revolve controls. + Lighting Освітлення + Revolve angle Кут обертання + Axis side Бік осі + Rotation X Обертання X + Rotation Y Обертання Y + Rotation Z Обертання Z + Shading Затінення + Direction Напрямок + Elevation Висота + Intensity Інтенсивність + Ambient Навколишнє освітлення + Segments Сегменти + Show axis Показати вісь + Reset all Скинути все + Reset to default Відновити типове значення + Reset all Revolve settings to their defaults. Відновити типові значення всіх параметрів обертання. + Drag values or labels. Double-click a value to type. Перетягуйте значення або підписи. Двічі клацніть значення, щоб увести його. + Drag rings to rotate. Shift snaps to 15°. Перетягуйте кільця для обертання. Shift задає крок 15°. + Drag the light, or its rings. Shift snaps to 15°. Перетягуйте джерело світла або його кільця. Shift задає крок 15°. + Keep visible surfaces only when expanding Під час розгортання зберігати лише видимі поверхні + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Обрізає приховану геометрію під час розгортання оформлення. Попередній перегляд зберігає всю поверхню. Прозорі заливки та поверхні, що перевищують обмеження видимості, зберігають усі грані. +lighting Light Світло diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9bd0df0ef..aedc6bce6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3668,3 +3668,29 @@ effect Vertical 垂直 Define a variable first 请先定义一个变量 Text 文本 Visibility 可见性 + +# Revolve profile guidance. + +# Interactive Revolve controls. + Lighting 照明 + Revolve angle 旋转成形角度 + Axis side 轴的位置 + Rotation X X 轴旋转 + Rotation Y Y 轴旋转 + Rotation Z Z 轴旋转 + Shading 明暗处理 + Direction 方向 + Elevation 仰角 + Intensity 强度 + Ambient 环境光 + Segments 分段数 + Show axis 显示轴 + Reset all 全部重置 + Reset to default 重置为默认值 + Reset all Revolve settings to their defaults. 将所有旋转成形设置重置为默认值。 + Drag values or labels. Double-click a value to type. 拖动数值或标签。双击数值即可输入。 + Drag rings to rotate. Shift snaps to 15°. 拖动圆环旋转。按住 Shift 以 15° 为步长调整。 + Drag the light, or its rings. Shift snaps to 15°. 拖动光源或圆环。按住 Shift 以 15° 为步长调整。 +lighting Light 光源 + Keep visible surfaces only when expanding 扩展时仅保留可见表面 + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. 扩展外观时裁剪被遮挡的几何区域。实时预览保留完整表面。透明绘制或超出可见性处理限制的表面保留所有面。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a4edda1d4..104d36173 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3449,3 +3449,29 @@ effect Soft 柔和 Define a variable first 請先定義一個變數 Text 文字 Visibility 可見性 + +# Revolve profile guidance. + +# Interactive Revolve controls. + Lighting 照明 + Revolve angle 旋轉成形角度 + Axis side 軸的位置 + Rotation X X 軸旋轉 + Rotation Y Y 軸旋轉 + Rotation Z Z 軸旋轉 + Shading 明暗處理 + Direction 方向 + Elevation 仰角 + Intensity 強度 + Ambient 環境光 + Segments 分段數 + Show axis 顯示軸 + Reset all 全部重設 + Reset to default 重設為預設值 + Reset all Revolve settings to their defaults. 將所有旋轉成形設定重設為預設值。 + Drag values or labels. Double-click a value to type. 拖曳數值或標籤。按兩下數值即可輸入。 + Drag rings to rotate. Shift snaps to 15°. 拖曳圓環旋轉。按住 Shift 以 15° 為間距調整。 + Drag the light, or its rings. Shift snaps to 15°. 拖曳光源或圓環。按住 Shift 以 15° 為間距調整。 +lighting Light 光源 + Keep visible surfaces only when expanding 展開時僅保留可見表面 + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. 展開外觀時裁剪被遮擋的幾何區域。即時預覽保留完整表面。透明繪製或超出可見性處理限制的表面保留所有面。 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e0ecb0b0b..4ddfd6287 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3362,6 +3362,10 @@ fn effect_menu() -> Vec { }; out.push(sub(sub_name, placeholder)); } else { + let mut items = items; + if sub_name == "3D and Materials" { + items.extend([todo("Extrude & Bevel…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")]); + } out.push(sub(sub_name, items)); } } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index dc25ceb64..3cd5716f9 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -213,11 +213,15 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } if app.ui.dialog.is_some() || app.ui.palette_open { + if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) && crate::dialogs::revolve_gizmo::dragging(ctx) { + // Escape first restores this rotation gesture. The settings stay open. + return; + } if crate::shortcut_editor::is_recording(app) { return; } if ctx.input(|i| i.key_pressed(Key::Escape)) { - app.ui.dialog = None; + crate::dialogs::cancel(app); app.ui.palette_open = false; } return; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 67a6f84b0..b2bf18fef 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -89,6 +89,10 @@ pub struct Tokens { pub error: Color32, /// Warning marks (a modified linked file). pub warning: Color32, + /// Rotation gizmo axes, also labelled X, Y and Z. + pub axis_x: Color32, + pub axis_y: Color32, + pub axis_z: Color32, } impl Tokens { @@ -133,6 +137,9 @@ impl Tokens { bleed: hex(0xf03030), error: hex(0xe34850), warning: hex(0xf0a330), + axis_x: hex(0xef6058), + axis_y: hex(0x45af63), + axis_z: hex(0x378ef0), }; match b { Brightness::Dark => base, diff --git a/docs/mcp.md b/docs/mcp.md index e5582c8f3..5650634fc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -819,6 +819,34 @@ default) white ones knock out instead; `"discard"` writes none. {"name":"run_command","arguments":{"command":"view.overprintPreview","params":{"on":true}}} ``` +## Revolve (initial 3D effect) + +`effect.apply {effect: "threeD.revolve", params: {...}, ids?, item?}` applies a live Revolve to a path +or compound path. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and +`effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source; +SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile. + +Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0), +`edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspective` +(0..100%, default 0), `segments` (8..128, default 64), `shade` (default true), `lightAzimuth` +(degrees, default -45), `lightElevation` (degrees, default 45), `lightIntensity` (0..100%, default 80), +`ambient` (0..100%, default 25), and `expandVisibleOnly` (boolean, default true). A vertical-line profile needs a positive offset to have a radius. +Open profiles have no end caps. Visibility of intersecting surfaces is approximate. +The dialog's Lighting tab manipulates the same `lightAzimuth` and `lightElevation` parameters; +its guide visibility and tab choice are UI state, excluded from saved effect parameters. +`expandVisibleOnly` is saved with the effect. It trims covered portions of opaque solid surfaces only +when running `effect.expandAppearance`, preserving visible interiors through openings. Set it to false +to expand all faces. Live preview and SVG/PDF baking keep the full surface. Transparent/unknown paint, +non-normal blending, later geometry effects or bounded visibility failures preserve all faces. +See [Revolve](revolve.md) for the scope and limitations. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.revolve","item":null,"params":{"angle":360,"offset":20,"edge":"left","rotationX":0,"rotationY":0,"rotationZ":0}}}} +{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"angle":180,"lightAzimuth":30}}}} +{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"expandVisibleOnly":true}}}} +{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{}}} +``` + ## Edit Colors and Recolor Artwork `edit.colors.invert`, `edit.colors.toCMYK`, `edit.colors.toGrayscale`, `edit.colors.toRGB`, `edit.colors.saturate`, diff --git a/docs/revolve.md b/docs/revolve.md new file mode 100644 index 000000000..9591ef739 --- /dev/null +++ b/docs/revolve.md @@ -0,0 +1,79 @@ +# Revolve + +This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567). + +Draw an open profile with the Pen tool, select it, then choose **Effect > 3D and Materials > Revolve**. +The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right +edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical +line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface. + +**Revolve angle** sweeps 0 to 360 degrees. **Rotation X/Y/Z** rotates the viewing direction, with all three axes defaulting to 0 degrees. **Perspective** +is zero for an orthographic view. **Segments** controls radial resolution (8 to 128 for a full turn). +Lighting is a directional light with azimuth, elevation, intensity and ambient contribution. +Turn **Shading** off to keep the source paint without lighting. + +The dialog has **Rotation** and **Lighting** tabs. Rotation groups the profile and view controls. Drag a value or its label +to adjust it, use its slider, or double-click the value to type. Angles show degrees and lighting +values show percentages. **Segments** is in the **Advanced** section. Lighting controls are disabled +when **Shading** is off. A filled dot at the end of a row means the value differs from its default; +click the dot to reset that value. **Reset all** restores every Revolve parameter to its default. + +The compact settings dialog opens beside the artboard. With **Preview** enabled, the selected +object has X (red), Y (green) and Z (blue) rotation rings directly on the artboard. Drag a ring to +adjust that axis; drag inside the rings to rotate freely. Hold Shift to snap to 15-degree increments. +The rings and the rotation fields stay in sync. Escape during a drag restores that gesture and +keeps the settings open. Escape again or Cancel restores the document before the dialog opened. +The gizmo appears while editing Revolve; normal source-path selection returns when the dialog closes. + +Select the **Lighting** tab to use the light gizmo on the artboard. Drag the sun handle to move +its direction on the current hemisphere. A hollow sun marks a back light. Drag the **Direction** +ring or its label horizontally to adjust azimuth; drag the **Elevation** arc or its label vertically +to adjust elevation. The labels remain usable when a ring is viewed edge-on or the light is at a pole. +Hold Shift to snap to 15-degree increments. Light gestures update their matching fields without +rotating the object, and keep the same temporary preview, Escape, Cancel and undo behaviour. +Light intensity and ambient contribution remain available as draggable fields and sliders. + +**Shading** off hides the light gizmo and disables lighting controls. Switch to **Rotation** to return +to the object rings. **Show axis** in **Advanced** displays the source's vertical axis on the object. +Turning **Preview** off restores the original artwork and hides artboard controls until Preview is enabled. +In small windows, the controls scroll while OK and Cancel stay visible. Reset dots restore individual +settings; **Reset all** restores parameters on both tabs. + +Preview changes are temporary until OK. Cancel restores the document; OK creates one undo step. +The source anchors remain editable. Appearance lists the effect for reopening its options. +SVG/PDF export evaluates filled vector faces; native saves retain the profile and effect parameters. +**Object > Expand Appearance** replaces the live effect with ordinary editable vector faces. + +In **Advanced**, **Keep visible surfaces only when expanding** is on by default. Expansion removes +completely covered faces and trims partially covered ones using the current rotation and perspective. +Visible interior surfaces through open ends remain. Uncheck it to retain every projected face. +The checkbox has its own reset dot and is included in **Reset all**. Its saved command parameter is +`expandVisibleOnly`; it does not change the live preview or SVG/PDF export baking. + +Trimming applies to opaque solid paint. Transparent objects, gradients, patterns, non-normal blending, +later geometry effects and surfaces exceeding bounded visibility limits keep their complete geometry +to preserve their appearance. Visibility follows the same painter order as the live effect, including +its approximation for intersecting surfaces. Expansion changes are undoable with the standard command. + +## Initial limits + +- Paths and compound paths only. Type, images and groups must be converted to paths first. +- Open profiles produce uncapped surfaces. Closed profile boundaries are swept as provided. +- One Revolve per source object. Edit its options rather than applying another. +- Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint. +- Flat shading, one directional light, no textures, cast shadows, end caps or physical materials. +- Faces use back-to-front depth sorting. Intersecting profiles can have incorrect visibility. +- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces. +- Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile. +- Extrude & Bevel, Inflate, Rotate and Materials remain unimplemented. + +## Architecture + +`vectorcraft-three-d` depends only on `vectorcraft-geom`. It creates a surface of revolution, +applies Euler view rotation, projects it, shades faces and sorts their depth. It has no UI or GPU dependency. +`vectorcraft-effects` turns those faces into document art. The renderer, export baking and +Expand Appearance use the same evaluator. The standard `effect.apply`, `effect.setParams`, +`effect.remove` and `effect.expandAppearance` commands expose it to the CLI, control channel and MCP. + +Behaviour reference: [public 3D effects documentation](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html). +This implementation uses original mathematics and source code, with no Adobe software, assets or presets. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index e72c0c309..d19942616 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -41,6 +41,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("trace", Class::Layer(2)), ("text", Class::Layer(2)), ("effects", Class::Layer(2)), + ("three-d", Class::Layer(2)), ("render", Class::Layer(3)), ("svg", Class::Layer(3)), ("pdf", Class::Layer(3)), @@ -78,6 +79,7 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[ // Non-native art in Illustrator editing data is a PDF, drawn by the PDF importer. &["pdf", "eps"], &["plugins", "effects"], + &["three-d", "effects"], ]; fn intra_layer_allowed(from: &str, to: &str) -> bool { From f443dde16f2293ee479e5e50f55335747cbcf8d5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 00:04:55 +0700 Subject: [PATCH 03/97] Review: German for Revolve, one row per catalog key, README The Revolve dialog and gizmo strings in German (the catalog landed after this branch was written), with Kreiseln and Aussehen umwandeln as the catalog already names them. Intensity, which main's Texture filters added meanwhile, is kept once per catalog. README's missing list says what 3D still lacks beyond the initial Revolve. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- crates/ui-egui/src/i18n/de.tsv | 22 ++++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 1 - crates/ui-egui/src/i18n/fr.tsv | 1 - crates/ui-egui/src/i18n/it.tsv | 1 - crates/ui-egui/src/i18n/ja.tsv | 1 - crates/ui-egui/src/i18n/ru.tsv | 1 - crates/ui-egui/src/i18n/uk.tsv | 1 - crates/ui-egui/src/i18n/zh-hans.tsv | 1 - crates/ui-egui/src/i18n/zh-hant.tsv | 1 - 10 files changed, 23 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 825df6619..0c27509c2 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0 **What's missing:** -- 3D and Materials; +- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); - the Photoshop-style raster effects (Effect Gallery); - CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface); - scripting, and the rest of Variables (image and graph kinds, dataset import); diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 58d4344e7..ca51323e1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3994,3 +3994,25 @@ theme Appearance Erscheinungsbild Define a variable first Definieren Sie zuerst eine Variable Text Text Visibility Sichtbarkeit +# 3D and Materials › Revolve: its options dialog and gizmos. + Ambient Umgebungslicht + Axis side Achsenseite + Direction Richtung + Drag rings to rotate. Shift snaps to 15°. Ziehen Sie die Ringe, um zu drehen. Mit Shift in 15°-Schritten. + Drag the light, or its rings. Shift snaps to 15°. Ziehen Sie das Licht oder seine Ringe. Mit Shift in 15°-Schritten. + Drag values or labels. Double-click a value to type. Ziehen Sie Werte oder Beschriftungen. Doppelklicken Sie auf einen Wert, um ihn einzugeben. + Elevation Höhe + Intensity Intensität + Keep visible surfaces only when expanding Beim Umwandeln nur sichtbare Flächen behalten + Lighting Beleuchtung + Reset all Alle zurücksetzen + Reset all Revolve settings to their defaults. Alle Einstellungen für „Kreiseln“ auf ihre Standardwerte zurücksetzen. + Reset to default Auf Standard zurücksetzen + Revolve angle Kreiselwinkel + Rotation X Drehung X + Rotation Y Drehung Y + Rotation Z Drehung Z + Segments Segmente + Shading Schattierung + Show axis Achse einblenden + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Entfernt bei „Aussehen umwandeln“ verdeckte Geometrie. Die Live-Vorschau behält die ganze Oberfläche. Transparente Farben und Oberflächen jenseits der Sichtbarkeitsgrenzen behalten alle Flächen. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4f5b09b33..82ed1583f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4050,7 +4050,6 @@ axis Both Ambos Shading Sombreado Direction Dirección Elevation Elevación - Intensity Intensidad Ambient Luz ambiental Segments Segmentos Show axis Mostrar eje diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 669e125cd..6b6e260a6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4060,7 +4060,6 @@ effect Soft Doux Shading Ombrage Direction Direction Elevation Élévation - Intensity Intensité Ambient Lumière ambiante Segments Segments Show axis Afficher l’axe diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 11bb0b561..eccca0d40 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4052,7 +4052,6 @@ effect Regular Normale Shading Ombreggiatura Direction Direzione Elevation Elevazione - Intensity Intensità Ambient Luce ambientale Segments Segmenti Show axis Mostra asse diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a68c40578..179e4b86d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4045,7 +4045,6 @@ effect Vertical 垂直 Shading 陰影 Direction 方向 Elevation 仰角 - Intensity 強度 Ambient 環境光 Segments 分割数 Show axis 軸を表示 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a1acb7957..a33169371 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4048,7 +4048,6 @@ axis Both Оба Shading Затенение Direction Направление Elevation Высота - Intensity Интенсивность Ambient Фоновое освещение Segments Сегменты Show axis Показать ось diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 28241c6d6..6de840be1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4031,7 +4031,6 @@ axis Both Обидві Shading Затінення Direction Напрямок Elevation Висота - Intensity Інтенсивність Ambient Навколишнє освітлення Segments Сегменти Show axis Показати вісь diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index aedc6bce6..59a88ce3e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3681,7 +3681,6 @@ effect Vertical 垂直 Shading 明暗处理 Direction 方向 Elevation 仰角 - Intensity 强度 Ambient 环境光 Segments 分段数 Show axis 显示轴 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 104d36173..77ac676d1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3462,7 +3462,6 @@ effect Soft 柔和 Shading 明暗處理 Direction 方向 Elevation 仰角 - Intensity 強度 Ambient 環境光 Segments 分段數 Show axis 顯示軸 From d4b0a5479b21ea3477c256548e83c843a4b66a20 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 14:11:14 -0400 Subject: [PATCH 04/97] Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#994) Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 15 +- CLAUDE.md | 15 +- README.md | 32 +-- ROADMAP.md | 373 ++++++++++------------------- docs/architecture.md | 73 ++++++ docs/effects-parity.md | 76 ++++++ docs/file-format-parity.md | 81 +++++++ docs/gaps.md | 207 ++++++++++++++++ docs/hardware-parity.md | 36 +++ docs/localization-parity.md | 66 ++++++ docs/roadmap.md | 84 +++++++ docs/target-app-parity.md | 462 ++++++++++++++++++++++++++++++++++++ docs/type-parity.md | 51 ++++ docs/ui-parity.md | 167 +++++++++++++ 14 files changed, 1462 insertions(+), 276 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/effects-parity.md create mode 100644 docs/file-format-parity.md create mode 100644 docs/gaps.md create mode 100644 docs/hardware-parity.md create mode 100644 docs/localization-parity.md create mode 100644 docs/roadmap.md create mode 100644 docs/target-app-parity.md create mode 100644 docs/type-parity.md create mode 100644 docs/ui-parity.md diff --git a/AGENTS.md b/AGENTS.md index ec28c2d4a..1731a4d39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else. ## Start every session here -1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list. +1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list. 2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it. 3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's. @@ -56,10 +56,15 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`). -## Roadmap -`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates. -- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank. -- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low. +## Roadmap and progress docs +The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): +- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log. +- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships). +- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus. +- Checklists: [`ui-parity.md`](docs/ui-parity.md), [`file-format-parity.md`](docs/file-format-parity.md), [`hardware-parity.md`](docs/hardware-parity.md), [`localization-parity.md`](docs/localization-parity.md), [`effects-parity.md`](docs/effects-parity.md), [`type-parity.md`](docs/type-parity.md); [`architecture.md`](docs/architecture.md). + +When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history. +- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated. - Keep the README's Status section in step with the ROADMAP headline. ## Contributor credits (About window) diff --git a/CLAUDE.md b/CLAUDE.md index 932d0b6d6..ca12182b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else. ## Start every session here -1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list. +1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list. 2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it. 3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's. @@ -56,8 +56,13 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`). -## Roadmap -`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates. -- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank. -- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low. +## Roadmap and progress docs +The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): +- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log. +- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships). +- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus. +- Checklists: [`ui-parity.md`](docs/ui-parity.md), [`file-format-parity.md`](docs/file-format-parity.md), [`hardware-parity.md`](docs/hardware-parity.md), [`localization-parity.md`](docs/localization-parity.md), [`effects-parity.md`](docs/effects-parity.md), [`type-parity.md`](docs/type-parity.md); [`architecture.md`](docs/architecture.md). + +When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history. +- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated. - Keep the README's Status section in step with the ROADMAP headline. diff --git a/README.md b/README.md index 0c27509c2..f86423fb9 100644 --- a/README.md +++ b/README.md @@ -198,11 +198,14 @@ For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instruc ## Status -VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the -milestones, and honest time-to-parity estimates. +VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the stage, the numbers by dimension +and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and +[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing. -**Where we are (2026-10-10):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of -"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools, +**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist +(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against +all of Illustrator): everyday +vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package). Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open @@ -213,22 +216,23 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files, and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs. [Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md). The interface -speaks English, German, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus). -The scores are -self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them. +speaks English, German, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus); +[localization-parity.md](docs/localization-parity.md) has the details. +The scores are self-assessed from our code against Illustrator's public documentation; +[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. **What's missing:** - +- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written); +- an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); -- the Photoshop-style raster effects (Effect Gallery); +- most of the Photoshop-style raster effects (20 of 57) and the Effect Gallery; - CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface); - scripting, and the rest of Variables (image and graph kinds, dataset import); -- an interaction-fidelity pass covering every tool's modifiers and small behaviours; -- packaging for Windows and Linux. +- generative AI features. -**Where we're going:** next is the interaction-fidelity pass alongside the raster-effects package, then 3D and -advanced type, then hardening and packaging for 1.0. The prioritized list is in -[Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order). +**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability, +alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in +[`docs/gaps.md`](docs/gaps.md). **Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}` and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so diff --git a/ROADMAP.md b/ROADMAP.md index 19ba2188a..c422d4c08 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,279 +1,148 @@ # VectorCraft Roadmap -VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux and the web (WASM), and agents can drive it fully over a JSON control channel and MCP. +**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -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 reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (full readiness ~62% by the standard's additive method; per-audience hours; essentials-user score ~75% added; ready for real work re-examined: 55% full, 65% mainstream; core-workflow gate applied: stays alpha; earlier the same day: full re-measure against Illustrator 2026 30.x, restructured to the craftrules progress-docs standard, detail moved to `docs/`) · **Target:** Adobe Illustrator 2026 (30.x) -_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 window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2), Variables (data merge): text and visibility variables, data sets and the Variables panel (#761), a window whose first frame never reaches the screen starting again on the next graphics processor (#964), Effect › 3D and Materials › Revolve as a live effect with rotation and light gizmos (#846, part of gap 3); see [Honest assessment](#honest-assessment-2026-10-05))._ +VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs +on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON +control channel. This page is the summary; the evidence is in [`docs/target-app-parity.md`](docs/target-app-parity.md) +and the work list in [`docs/gaps.md`](docs/gaps.md). -## Where we are +**Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance, +type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's +own `.ai` files open with a wrong layer structure in real cases and can't be saved back with Illustrator's +editing data, the interaction details have never been checked side by side, and some machines fail to start the +app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow +gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print +production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without +blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). -| Dimension | Status | -|---|---| -| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** | -| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** | -| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) | -| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** | -| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~210–330 h** one agent · **~55–95 h** with 4–6 agents | -| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~320–500 h** one agent · **~80–145 h** with 4–6 agents | +## Headline numbers -## Honest assessment (2026-10-05) - -**In one line:** about two-thirds to three-quarters of Illustrator's features exist and work, and for everyday -vector illustration (drawing, paths, paint and appearance, SVG/PDF/print) VectorCraft is close to usable. Measured -against "a power user can't tell the difference", it is closer to 40–55%. What stands between here and "anyone can -switch" is a handful of large subsystems plus a fidelity pass, not a long tail of small fixes. - -**How much to trust the numbers:** area scores are graded by the agents that built the features, from behaviour docs -(`plan/illustrator/`) and tests, not by side-by-side use of Illustrator. Treat them as upper bounds. Look & feel is -the only dimension measured against the reference app. Performance budgets haven't been re-run on an idle machine -since 2026-10-01. - -### By dimension - -| Dimension | Estimate | Evidence and what's missing | +| | Value | Kind | |---|---|---| -| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | -| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) | -| **Large missing subsystems** | 0–20% | 3D & Materials (~8%, initial Revolve only), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting | -| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | -| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | -| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **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 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; what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) | -| **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) | +| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 20/57, 3D 1/4 (initial Revolve) | +| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | +| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | +| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated | -### Where we're lacking (in priority order) +**Readiness by audience** (all estimated; method in [target-app-parity.md](docs/target-app-parity.md#ready-for-real-work)): -Ordered by how much each gap blocks someone from switching. Sizes are one-agent hours from the parity table. +| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates | +|---|---:|---:|---:|---| +| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (30–50 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem | +| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas | +| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish | -1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`, - `06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and - isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h. -2. **Photoshop-style raster effects and the Effect Gallery:** about 36 filters (Artistic, Brush Strokes, Sketch, - Stylize) and the Effect Gallery. The pixel-filter pipeline is in place - (`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and - Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur - (Normal mode only), Distort › Diffuse Glow, Glass (its Blocks, Canvas, Frosted and Tiny Lens surfaces made in - code; Load Texture is missing) and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, - Stylize › Glowing Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (its - Brick, Burlap, Canvas and Sandstone surfaces made in code; Load Texture is missing) and Video › De-Interlace and - NTSC Colors; each new filter is now one function, a catalogue row, menu and translations. Parallelizes - well across agents. 19–31 h. -3. **3D and Materials:** initial Revolve has an independent software geometry crate, editable profiles, - shaded vector output and basic lighting. Extrude & Bevel, Inflate, Rotate, materials, caps and more complete - visibility remain. The subsystem is still an early implementation. 45–75 h. -4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, proportional vertical metrics, manual - tate-chu-yoko), Optical Margin Alignment, the hyphenation and justification options (the Every-line/Single-line - composer choice is done), tab leaders, a - spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have - initial support. 34–47 h across type core and advanced. -5. **Brushes, symbols and libraries:** brush options depth (Brush Options exist for every brush type, Calligraphic - and Scatter with pen pressure; still missing are the Art brush's Overlap, auto-generated Pattern brush corners, - new tile art picked in Pattern Brush Options and the Key Color eyedropper); original brush, symbol and - graphic-style libraries generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by - pen tilt, bearing and barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every - platform. 17–28 h. -6. **Automation:** a scripting surface over the command registry, batch, and the rest of - Variables (data merge): image and graph variable kinds, dataset import/export. 6–10 h. -7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video - rulers. 15–25 h. -8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, Affinity files from more versions and platforms - (51 pinned files now include 28 original Affinity 3.2.3 feature probes; large interacting documents, - complex typography and independently paired `.af`/PSD renders reopened in Affinity remain missing), idle-machine perf budgets, Windows, Linux and - browser QA, pen tablets on Wayland and pen pressure beyond Windows (winit 0.31's tablet input, #491), accessibility, packaging. 50–80 h. +Hours are Opus 5.5 agent wall-clock hours, calibrated from this repo's merged-PR timestamps (e.g. 0.7–1.2 h per +Photoshop-style filter, ~0.8 h per medium feature); see [calibration](docs/target-app-parity.md#calibration). +The installed Illustrator was **not** inspected: the clean-room rule in [`AGENTS.md`](AGENTS.md) forbids it, so +everything is measured from our code against Illustrator's public documentation. -### Where we're going +## By dimension -- **Next:** start the interaction-fidelity pass (1) in parallel with the raster-effects package (2). Both are broad, - so they split well across agents, and they move the "can't tell the difference" number most. -- **Then:** 3D (3) and advanced type (4), each a focused milestone with its own plan in `plan/` before coding. -- **Alongside:** keep closing the stubbed menu items. Run `vectorcraft-cli perf` on an idle machine, and fix any - budget it misses before 1.0. -- **1.0:** feature parity plus the fidelity pass, hardening (8) and packaging for all three desktop OSes and the web. +| Dimension | % | One agent (h) | Doc | +|---|---:|---:|---| +| Features (depth) | 75% | 220–360 | [target-app-parity.md](docs/target-app-parity.md) | +| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) | +| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) | +| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) | +| Localization (12 key languages) | 54% (measured) | 70–110 | [localization-parity.md](docs/localization-parity.md) | +| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) | +| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) | +| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) | +| Ecosystem and plug-ins | 15% | 30–60 | [gaps.md › G17](docs/gaps.md#g17-ecosystem-third-party-plug-ins) | +| AI features (generative) | 0% | 30–60 + owner decision | [gaps.md › G18](docs/gaps.md#g18-generative-ai-features) | +| Agent automation | beyond Illustrator | — | [mcp.md](docs/mcp.md) | -**For agents:** pick work from the list above (or the milestone rows below), and write a task plan before coding. -When a task lands, update this section, the parity table and "Shipped so far" in the same PR. Keep the scores honest: -grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. +The dimension hours overlap (feature rows hold some UI, format and AI work), so they don't add up to the total. -## Shipped so far +## Features -- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. -- **Automation:** - - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - - Variables (data merge): text and visibility variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility Dynamic, Capture/Update Data Set, and a row in italics when an edit leaves the art not matching it; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. - - 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. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832). - - 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`). 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. - - Hint bar, contextual task bar, 19 dock panels with ≡ menus. - - The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it). - - Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it. - - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels. - - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, German, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. - - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). - - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. - - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - - Four brightness themes, persistent preferences. - - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). About 17 stored options still do nothing yet (the rest of #394: Highlight Alternate Glyphs, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace). - - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - - Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off. - - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. - - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields. - - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. -- **Tools:** - - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain. - - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). - Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). - Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786). - - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. - - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. - - **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). - - **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). - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph Design, Column and Marker artwork remain open. - - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. - - **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked. - - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. - - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. - - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel. -- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). -- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. -- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). -- **Geometry and effects:** - - Initial live Revolve (#605): a portable geometry and shading crate, editable path/compound profiles, compact options with artboard rotation and light gizmos, numeric scrubbing/sliders/reset, command access, SVG/PDF vector output and Expand Appearance. A Revolve-specific expansion option trims covered opaque solid surfaces while preserving visible interiors. Open ends are uncapped; shading is flat and intersecting-surface visibility approximate. - - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. - - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). -- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). -- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. -- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. -- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. -- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). -- **Paint and appearance (M3):** - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. - - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). - - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. - - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. - - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. - - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. - - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. - - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. - - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. -- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. -- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). -- **Files (M4):** - - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). - - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. - - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [scope, provenance and limits](crates/affinity/README.md). - - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. - - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). -- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. -- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland. -- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). +| Area | % | One agent (h) | +|---|---:|---:| +| Selection, transform & align | 85% | 4–6 | +| Drawing tools | 85% | 8–13 | +| Path operations, Pathfinder, Shape Builder, Live Paint | 80% | 4–8 | +| Colour, swatches, gradients, patterns, mesh, recolor | 92% | 4–7 | +| Strokes, brushes, width profiles | 88% | 8–15 | +| Appearance, transparency, graphic styles, masks | 97% | 1–2 | +| Live vector effects | 85% | 6–10 | +| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 40% | 30–50 | +| 3D and Materials | ~8% | 50–85 | +| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 | +| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 | +| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 | +| Image Trace, graphs, image tools | 75% | 5–8 | +| Layers, artboards, document setup | 78% | 5–8 | +| View & navigation | 70% | 9–14 | +| Guides, grids, smart guides, snapping | 80% | 4–7 | +| File formats — [file-format-parity.md](docs/file-format-parity.md) | 75% | 10–20 | +| Export for Screens, Asset Export, slices | 85% | 2–4 | +| Print, colour management, separations | 80% | 2–4 | +| Automation (Actions, Variables, scripting) | 60% | 8–14 | +| UI chrome (panels, Properties, preferences) | 80% | 13–20 | +| Libraries, Links, Package | 82% | 2–3 | +| Generative AI | 0% | 30–60 | -## Milestones and estimates +Weights, evidence and the missing items per area: [target-app-parity.md](docs/target-app-parity.md#feature-areas). -| # | Milestone | Status | Est. remaining, one agent (h) | -|---|---|---|---| -| M0 | Skeleton + vertical slice | ✅ done | — | -| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | -| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | -| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | -| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | -| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | -| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 | -| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; graph Design/Column/Marker designs pending | 6–10 | -| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | -| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 | -| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 | -| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** | +## Languages -## Parity estimate +| Language | Code | Status | Translated | +|---|---|---|---:| +| English | `en` | full | 100% | +| Simplified Chinese | `zh-hans` | partial (messages in English) | 85% | +| Spanish | `es` | full | 100% | +| Hindi | `hi` | none | 0% | +| Arabic | `ar` | none (no right-to-left interface) | 0% | +| French | `fr` | full | 100% | +| Portuguese (Brazil) | `pt-br` | partial | 68% | +| Indonesian | `id` | none | 0% | +| Japanese | `ja` | full | 100% | +| German | `de` | full | 98% | +| Korean | `ko` | none | 0% | +| Vietnamese | `vi` | none | 0% | -_Method (2026-10-02; M3 rows re-derived 2026-10-04, file, export, print, links and UI-chrome rows after M4 on 2026-10-05):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of -real users' work) each represents. Each area is scored by depth of behaviour, not by presence of a menu item: an area -is 100% only when every feature in it behaves like Illustrator. Feature parity = Σ weight × score / Σ weight. Remaining -time is counted per area from what is missing, calibrated on measured throughput: in the last session one agent landed -about 20 medium features (Text Wrap, Graphs, Effect → Pathfinder, export baking…) in ~16 h wall clock including builds -and CI on a heavily loaded machine — about 0.8 h per medium feature; large subsystems (3D, raster filters, vertical type) -are counted bottom-up. The M3 rows (colour, strokes, appearance) come from a detailed 92-task M3 plan whose review -found more missing depth than the first estimate; all 92 tasks landed on 2026-10-04 and 2026-10-05 with 6–8 agents in parallel; its packages, planned at 5–11 h each, took -about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) are likely high. +Also shipped: Traditional Chinese (partial, 80%), Italian, Russian and Ukrainian (full), Czech (menus only). +No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md). -| Area | Weight | Done | Missing (main items) | One agent (h) | -|---|---:|---:|---|---:| -| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 | -| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 | -| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | -| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | -| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 | -| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | -| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 | -| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 | -| 3D and Materials | 4 | ~8% | Initial Revolve only, with simple lighting and Revolve-specific visible-surface expansion; Extrude & Bevel, Inflate, Rotate, caps, materials, shadows and more complete visibility remain | 45–75 | -| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 | -| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | -| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), 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 (Move Artwork for artboard X/Y edits, 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; 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 (Photoshop Import Options; the merged image is done), 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 | -| Automation | 3 | 65% | scripting surface, batch (Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | -| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 13–20 | -| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 | -| **Feature parity** | **103** | **~75%** | | **~210–330** | -| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 | -| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 | -| **Full parity** | | **~56%** | | **~320–500** | +## Upcoming -With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4 -(integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity, -**~80–145 h** to full parity. +Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md). -_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool -implemented except Touch Type; 51 panel modules; Illustrator-style live -effects ~44/54, Photoshop-style raster effects ~4/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust. +1. **Interaction-fidelity pass** (G1), tool by tool from [ui-parity.md](docs/ui-parity.md): 60–90 h. +2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h. +3. **Launch and stability on real machines** (G10): 20–35 h. +4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h. +5. **Raster effects** (G2) alongside: 37 filters and the Effect Gallery, 30–50 h. +6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3). -_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out, -lossless SVG/PDF export of live effects with SVG filters, a documented JSON format, the same app on the web, and every -command, gesture and dialog drivable by agents (MCP, CLI, control channel). +## Progress log -## Out of scope (by design or by law) -- **Native `.ai` private data, beyond its structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in. -- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original. +| Date | What landed | +|---|---| +| 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 | +| 2026-10-07 | v0.4.0; issue fixes; Layers panel rework | +| 2026-10-06 | M8 transform and distort fidelity pass (M8.1–M8.20) | +| 2026-10-05 | M4 files done (M4.14–M4.98); M3 paint and appearance done (92 tasks over two days) | +| 2026-10-02 | First weighted parity estimate (22 areas); look and feel measured against public screenshots | +| 2026-10-01 | Renamed from DrawCraft to VectorCraft | +| 2026-09-30 | Repository history begins | -## Where we aim to be better than Illustrator -- **Speed:** off-thread multithreaded rendering, instant startup, a responsive UI on huge files. -- **Robustness:** exact curve booleans (no "cannot perform operation"), property-tested undo/redo and file round trips. -- **Openness:** a documented native format, first-class SVG, and the same app in the browser. -- **Automation:** every command, gesture, dialog and widget is drivable by agents (JSON channel + MCP), with headless batch mode. +The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/roadmap.md#progress-log-before-the-progress-docs-standard). -## How to update this file -After each milestone task lands, update the status column and remaining estimates, and move items into "Shipped so far". Keep estimates honest: re-derive them from what remains, never from wishful velocity. +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Method aligned with the standard, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away | +| 2026-10-10 | minor | Added the essentials-user readiness score (~75%) | +| 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md | +| 2026-10-10 | minor | Applied the core-workflow gate (docs/roadmap.md › Alpha gate): passes, stage stays alpha | +| 2026-10-10 | major | Re-measured against Illustrator 2026 (30.x) from code counts, 71 open issues and public docs; stage set to alpha; moved the parity estimate, honest assessment and "Shipped so far" to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, milestones to `docs/roadmap.md`, the `.ai` scope to `docs/file-format-parity.md` | +| 2026-10-05 | major | Honest assessment by dimension and the prioritized gap list | +| 2026-10-02 | major | First 22-area weighted parity estimate | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..5bca67dcb --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,73 @@ +# Architecture + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, written from the workspace as it is on origin/main `01e165af`) · **Target:** Adobe Illustrator 2026 (30.x) + +How VectorCraft is built today. The design rationale lives in the local, gitignored `plan/architecture.md`; +this file describes what is committed. Day-to-day rules (clean-room, never crash, quality gates) are in +[`AGENTS.md`](../AGENTS.md) and [development.md](development.md). + +## Shape + +A Cargo workspace of 23 library crates under `crates/`, three apps under `apps/` and `xtask`, ~354k lines of Rust, +pure Rust (no handwritten JS/TS). The same engine runs the desktop app, the browser build (WASM), the headless +CLI and the MCP server. + +## Layers + +`cargo xtask layers` (part of `cargo xtask ci`) enforces that a crate depends only on lower layers, and that +nothing below L6 depends on a UI toolkit (egui, eframe, winit, rfd). Table: `xtask/src/layers.rs`. + +| Layer | Crates | Role | +|---|---|---| +| L0 | `geom`, `color`; `affinity` (standalone, no workspace deps) | curves and transforms (on `kurbo`), colour models, ICC and conversions; the Affinity reader | +| L1 | `doc` | the document model: artboards, layers, objects, appearance, text, symbols; pure data + serde, structural sharing | +| L2 | `pathops`, `brush`, `trace`, `text`, `effects`, `plugins`, `three-d` | booleans/Shape Builder/offset/outline; brushes; Image Trace; fonts, shaping (HarfRust, skrifa), bidi and layout; live effects and the pixel-filter pipeline; sandboxed WebAssembly plug-ins (wasmi); 3D surfaces of revolution, projection and shading (`three-d`, on `geom` only) | +| L3 | `render`, `svg`, `pdf`, `format`, `eps`, `cad`, `metafile` | document to pixels (`vello_cpu`, multithreaded); SVG, PDF, native JSON, EPS/PostScript (with its own interpreter), DXF, EMF/WMF readers and writers | +| L4 | `tools` | tool state machines: pointer events in, commands and overlays out (Pen, Direct Selection, smart guides…) | +| L5 | `engine` | sessions, history (unlimited undo by structural sharing), selection, the command registry (~710 commands), file I/O, the panic guard | +| L6 | `ui-egui`, `mcp` | the egui front end (menus, panels, dialogs, canvas, i18n); the MCP server (JSON-RPC over stdio) | +| L7 | `apps/vectorcraft`, `apps/vectorcraft-cli`, `apps/vectorcraft-web`, `xtask` | desktop app (eframe, wgpu), CLI (`run`, `convert`, `info`, `bench`, `perf`, `mcp`), browser runner (WebGPU, WebGL2 fallback), build tooling | + +`testkit` holds test helpers (fixtures, generators, render comparison) and is only a dev-dependency. + +## Everything is a command + +Every user-visible action is a `CommandSpec` in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, +params doc, `enabled`, `run`, journal flag); UI-only commands are `UI_COMMANDS` in +`crates/ui-egui/src/menus.rs`. Tools emit commands (begin, preview, commit). Menus, the command palette, Actions, +the control channel, the CLI and MCP all reach the same registry, so anything a user can do an agent can do. +`command.batch` runs several as one undo step. + +## Rendering + +The canvas is rasterized on the CPU by `vello_cpu` on worker threads, off the UI thread, with caches per object +and per effect; the GPU (wgpu: Metal, DX12, Vulkan/GL; WebGPU/WebGL2 on the web) only composites the result. Raster +effects are rendered offscreen per object and cached while panning. See +[hardware-parity.md](hardware-parity.md). + +## Files + +The native `.vectorcraft` format is versioned, compressed JSON with embedded images, saved atomically, readable +down to v1 and from the old `.drawcraft` name. The `FORMATS` table in `crates/engine/src/cmd/fileio/mod.rs` +lists every format read or written; importers are fuzzed (`crates/engine/tests/import_fuzz.rs`). See +[file-format-parity.md](file-format-parity.md). + +## Agent control + +- **Control channel:** JSON lines on a local TCP port (`--control 7979`), with real egui pointer and keyboard + injection and screenshots ([control-protocol.md](control-protocol.md)). +- **MCP:** `vectorcraft-cli mcp`, attached to a running app or headless ([mcp.md](mcp.md)). +- **Sandboxing:** `--automation-read-root` / `--automation-write-root` confine what agents read and write. + +## Robustness + +No panics in shipped code (workspace clippy lints ban `unwrap`, `expect`, `panic!` and friends outside tests); +`vectorcraft_engine::guard` catches a panic at every entry point and rolls the document back; Data Recovery +saves copies in the background. See [development.md](development.md#robustness-vectorcraft-never-crashes). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Added the `three-d` crate | +| 2026-10-10 | major | First version, from the workspace, the layering table and the existing docs | diff --git a/docs/effects-parity.md b/docs/effects-parity.md new file mode 100644 index 000000000..cd2e7a83c --- /dev/null +++ b/docs/effects-parity.md @@ -0,0 +1,76 @@ +# Effects parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, counted from the effects catalogue) · **Target:** Adobe Illustrator 2026 (30.x) + +Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue +(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it +(`crates/ui-egui/src/menus.rs`). Counts are **measured** from the catalogue; "depth" notes are estimated. +Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](gaps.md). + +## Summary + +| Group | Illustrator | VectorCraft | % | Remaining (h) | +|---|---:|---:|---:|---:| +| Illustrator effects (vector and stylize) | 50 | 44 | 88% | 6–10 | +| Photoshop effects (raster, Effect Gallery) | 57 | 20 | 35% | 30–50 | +| 3D and Materials | 4 (+3 Classic) | 1 (initial Revolve) | ~8% | 45–75 | +| Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — | + +## Illustrator effects + +| Submenu | Items | Ours | Missing | +|---|---|---:|---| +| Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — | +| Crop Marks | Crop Marks | 1/1 | — | +| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885) | +| Path | Offset Path, Outline Object, Outline Stroke | 2/3 | Outline Object | +| Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 10/13 | Hard Mix, Soft Mix, Trap | +| Rasterize | Rasterize… (as a live effect) | 0/1 | the live effect (Object › Rasterize exists) | +| Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | — | +| SVG Filters | Apply SVG Filter… and the filter list | 0/1 | the whole submenu (SVG filters on import round-trip already) | +| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes | + +Document Raster Effects Settings exists. + +## Photoshop effects + +| Submenu | Items | Ours | Missing | +|---|---|---:|---| +| Artistic | Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting, Watercolor | 0/15 | all | +| Blur | Gaussian Blur, Radial Blur, Smart Blur | 3/3 | Smart Blur's Edge Only and Overlay Edge modes | +| Brush Strokes | Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e | 0/8 | all | +| Distort | Diffuse Glow, Glass, Ocean Ripple | 3/3 | Glass's Load Texture | +| Pixelate | Color Halftone, Crystallize, Mezzotint, Pointillize | 4/4 | — | +| Sharpen | Unsharp Mask | 1/1 | — | +| Sketch | Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges, Water Paper | 0/14 | all | +| Stylize | Glowing Edges | 1/1 | — | +| Texture | Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer | 6/6 | Texturizer's Load Texture | +| Video | De-Interlace, NTSC Colors | 2/2 | — | +| Effect Gallery | the combined browser and stack dialog | 0/1 | all | + +The pixel-filter pipeline (`vectorcraft_effects::pixel`) is in place: each new filter is one function, a +catalogue row, a menu entry and translations. Measured throughput: 0.7–1.2 agent hours per filter +(see [calibration](target-app-parity.md#calibration)), so the 37 missing filters are ~26–44 h and the Effect +Gallery dialog another 4–6 h. These parallelize well across agents. + +## 3D and Materials + +| Item | Ours | +|---|---| +| Revolve | initial: live, editable profile, rotation and light gizmos, flat shading, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | +| Extrude & Bevel, Inflate, Rotate | none (menu stubs) | +| Materials panel, lighting, ray-traced render | none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | +| Turntable (30.0 beta) | none | +| Export 3D objects (OBJ, USDA, glTF) | none | + +The `three-d` crate (depends only on `geom`) builds, rotates, projects, shades and depth-sorts the surfaces; +the effects crate turns them into art. What's left (Extrude & Bevel, Inflate, Rotate, caps, materials, ray-traced +rendering, Turntable, 3D export) is 45–75 h, still the largest single gap. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Initial Revolve landed (#846) | +| 2026-10-10 | major | First checklist, counted from the effects catalogue (20/57 Photoshop effects, 44/50 Illustrator effects) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 000000000..de29c4679 --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,81 @@ +# File format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, from the `FORMATS` table and Illustrator 2026's documented formats) · **Target:** Adobe Illustrator 2026 (30.x) + +Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, +`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public +documentation (helpx "file formats" and Save/Export pages), not from its installed bundle: the clean-room rule +forbids reading the install. Part of [target-app-parity.md](target-app-parity.md). + +**Fidelity** is estimated unless a test is named. Test sources: unit and property tests per crate +(`crates/svg/tests`, `crates/pdf/tests`, `crates/format/tests/prop_format.rs`), import fuzzing +(`crates/engine/tests/import_fuzz.rs`), the Affinity corpus (`cargo xtask corpus --affinity`, 51 pinned public +files, CI job `affinity-corpus.yml`). There is **no real-world Illustrator corpus**: test inputs are written +from the public specifications, and users' own `.ai` files are used locally only. + +## Summary + +| | Illustrator | VectorCraft | Notes | +|---|---:|---:|---| +| Formats opened or placed | ~30 | 22 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, PCX, Pixar, RTF/Word, PS | +| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written as PDF-compatible only (no Illustrator editing data) | +| Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | +| **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | + +## The main format: `.ai` + +The format that decides beta. Illustrator's `.ai` is a PDF (or, for old files, PostScript) plus Illustrator's +private editing data. VectorCraft: + +- **Reads** the PDF page (vectors, text, images, colour, transparency, layers via optional content) and the + *structure* of the editing data (below). Opens Illustrator EPS the same way through its own PostScript + Level 3 interpreter. +- **Writes** a PDF-compatible `.ai` (Save As › `ai`): Illustrator opens it as plain art. It never writes + Illustrator's private editing data, which is undocumented. +- **Known failures in users' files:** layer structure flattened or multiplied (#951: 109 layers instead of + 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction + lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects, + brushes, symbols and pattern fills come in as their drawn look (#637), legacy `.ai` without the prolog not + read. +- Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible only). + Closing the open-side issues: 10–20 h with users' files. + +### Illustrator's private editing data + +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. + +## Format by format + +| Format | Illustrator | Ours: open/place | Ours: save/export | Fidelity and tests | +|---|---|---|---|---| +| VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`) | +| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure) | PDF-compatible only | see above; no real-file corpus | +| Illustrator template `.ait` | open, save | yes | no (save as `.vctemplate`) | | +| PDF | open, place, save | yes (layers, masks, editable text, security) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; PDF text placement issues in some files (#722) | +| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type and CCITTFax images not read; #505 | +| PostScript `.ps` | open | no | via Print to PostScript | | +| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests` | +| DXF | open, export | yes | yes (R12–2018) | | +| DWG | open, export | no | no | no open spec; DXF instead | +| EMF / WMF | open, export | yes | yes | | +| PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing | +| PSB | open | yes (merged image) | no | | +| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes | Targa import missing | +| WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | +| HEIC / HEIF | open, place | no | no | | +| JPEG 2000 | open, place | no | no | | +| PICT, PCX, Pixar PXR | open, place (legacy) | no | no | rarely used | +| FreeHand `.fh*` | open (legacy) | no | no | | +| CorelDRAW `.cdr` | open | no | no | | +| Text `.txt` | place, export | place (Text Import Options) | yes | | +| RTF / Word `.doc`/`.docx` | place | no | no | | +| CSS | export (CSS Properties) | — | yes | | +| OBJ / USDA / glTF | 3D export | — | no | needs 3D (gap G3) | +| Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) | +| Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First checklist; moved the `.ai` editing-data scope from the ROADMAP's "Out of scope" here; added the user-reported `.ai` failures | diff --git a/docs/gaps.md b/docs/gaps.md new file mode 100644 index 000000000..405d06da5 --- /dev/null +++ b/docs/gaps.md @@ -0,0 +1,207 @@ +# Gaps + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (moved from the ROADMAP's "Where we're lacking", re-ranked, added file-format, stability, hardware, localization, ecosystem and AI gaps) · **Target:** Adobe Illustrator 2026 (30.x) + +Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from +switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make +progress on, write a plan, and update the entry (and [target-app-parity.md](target-app-parity.md)) in the same PR. + +Gap ids are stable (PRs say "Part of gap 2"); the rank can change. Hours are Opus 5.5 agent wall-clock hours for +one agent, estimated unless marked; calibration in +[target-app-parity.md](target-app-parity.md#calibration). **Beta** marks the gaps that must close before the beta +stage ([ROADMAP.md](../ROADMAP.md)). + +## Ranked + +| Rank | Gap | Hours | Beta | Doc | +|---:|---|---:|:---:|---| +| 1 | [G1 Interaction fidelity](#g1-interaction-fidelity) | 60–90 | yes | [ui-parity.md](ui-parity.md) | +| 2 | [G9 Illustrator `.ai` and EPS files from real users](#g9-illustrator-ai-and-eps-files-from-real-users) | 10–20 | yes | [file-format-parity.md](file-format-parity.md) | +| 3 | [G10 Launch and stability on real machines](#g10-launch-and-stability-on-real-machines) | 20–35 | yes | — | +| 4 | [G8 Hardening at scale](#g8-hardening-at-scale) | 30–50 | yes (part) | — | +| 5 | [G11 Correctness bugs in core tools](#g11-correctness-bugs-in-core-tools) | 8–15 | yes | [target-app-parity.md](target-app-parity.md) | +| 6 | [G4 Advanced type](#g4-advanced-type) | 34–47 | core part | [type-parity.md](type-parity.md) | +| 7 | [G12 Preferences and panels that don't act yet](#g12-preferences-and-panels-that-dont-act-yet) | 13–20 | yes | [ui-parity.md](ui-parity.md) | +| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 30–50 | no | [effects-parity.md](effects-parity.md) | +| 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 15–25 | no | [target-app-parity.md](target-app-parity.md) | +| 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 8–16 | macOS pressure | [hardware-parity.md](hardware-parity.md) | +| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 17–28 | no | [target-app-parity.md](target-app-parity.md) | +| 12 | [G3 3D and Materials](#g3-3d-and-materials) | 45–75 | no | [effects-parity.md](effects-parity.md) | +| 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) | +| 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 8–14 | no | [target-app-parity.md](target-app-parity.md) | +| 15 | [G15 Performance budgets and a GPU renderer](#g15-performance-budgets-and-a-gpu-renderer) | 15–25 | budgets only | [hardware-parity.md](hardware-parity.md) | +| 16 | [G16 Localization: the five missing key languages](#g16-localization-the-five-missing-key-languages) | 70–110 | no | [localization-parity.md](localization-parity.md) | +| 17 | [G17 Ecosystem: third-party plug-ins](#g17-ecosystem-third-party-plug-ins) | 20–45 | no | — | +| 18 | [G18 Generative AI features](#g18-generative-ai-features) | 30–60 | no | — | +| 19 | [G19 Accessibility](#g19-accessibility) | 15–25 | no | [ui-parity.md](ui-parity.md) | + +**Alpha blockers:** none. Every core workflow passes the [alpha gate](roadmap.md#alpha-gate); G9 is the only +partial one, and it blocks beta, not alpha. + +**To beta:** G1, G9, G10, G11, G12, the corpus and QA part of G8, the type-core part of G4, macOS pen pressure +from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall clock with 4–6 agents. + +## G1 Interaction fidelity + +- **Missing:** a tool-by-tool, panel-by-panel pass over modifiers, cursors, the Properties panel per context, + isolation mode and small behaviours, against the public tool and shortcut documentation. Itemized in + [ui-parity.md](ui-parity.md). The Shaper's construction-mode widget and gesture refinements remain. +- **Evidence:** no side-by-side session has ever happened (the clean-room rule forbids running Illustrator, so + the pass works from the documentation); users keep reporting small differences: #991, #975, #973, #955, #908. +- **Impact:** a power user notices this in the first minutes and stops trusting the tool. +- **Estimate:** 60–90 h; splits well by tool across agents. + +## G9 Illustrator `.ai` and EPS files from real users + +- **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead + of 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction + lines (#779), open speed on large files (#758), legacy `.ai` without the prolog, live effects, brushes, symbols + and pattern fills as live objects (#637). Writing Illustrator's own editing data is out of scope + (undocumented); Save As `.ai` stays PDF-compatible. +- **Evidence:** the issues above; 28 of the 350 issues are about `.ai`/EPS (about 22 people, the most common single + format theme), 20 fixed, and the 8 open ones are about structure (layers, guides, speed, live objects), not missing + art. No real-file `.ai` corpus (users' files may be used locally, never committed). +- **Impact:** blocks anyone who hands files back to Illustrator users. Opening and reworking their own archive + works, with layer cleanup in some files. This is the main-format gap that keeps the stage at alpha. +- **Estimate:** 10–20 h with users' files to test against. + +## G10 Launch and stability on real machines + +- **Missing:** the app not starting or staying in the background on some Windows 11 machines (#858, #964 fixed + 2026-10-10, #620, #818), slow pointer on a Linux AppImage (#575), macOS paste from other apps (#954), a + web-free crash/hang reporter, a field record. +- **Evidence:** open issues; the never-crash lints and `guard` stop panics, not driver, windowing or packaging + failures. +- **Impact:** an app that doesn't start on someone's machine is 0% for them. +- **Estimate:** 20–35 h, needs reporters' help on hardware we don't have. + +## G8 Hardening at scale + +- **Missing:** a corpus of real-world SVG/PDF/EPS files; more Affinity versions and platforms (51 pinned files + now, including 28 original Affinity 3.2.3 feature probes; large interacting documents and complex typography + missing); Windows, Linux and browser QA by hand; packaging polish (Homebrew cask #588, Flathub #554, update + checks #615). +- **Impact:** unknown failures on users' files. +- **Estimate:** 30–50 h (the corpus and QA part blocks beta). + +## G11 Correctness bugs in core tools + +- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Asset Export + anti-aliasing ghost borders (#983); Transform effect dialog's invisible checkboxes and missing options (#885); + PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834). +- **Impact:** wrong output in everyday work. +- **Estimate:** 8–15 h. + +## G4 Advanced type + +- **Missing:** hyphenation and justification options, Optical Margin Alignment (type core); CJK vertical + composition (ruby, mojikumi sets and dialog, proportional vertical metrics #966, kinsoku settings #633, manual + tate-chu-yoko), Middle Eastern features beyond bidi, variable font axes, spell check with an open dictionary, + Touch Type, Retype, Snap to Glyph. Itemized in [type-parity.md](type-parity.md). +- **Impact:** professional typography and Japanese/Chinese publishing. +- **Estimate:** 34–47 h (type core 14–19 h blocks beta). + +## G12 Preferences and panels that don't act yet + +- **Missing:** ~17 Preferences options stored but not acted on (#394: Highlight Alternate Glyphs, Open + Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…), Properties panel per + context, context menus beyond the canvas, Layers panel row menus and Collect for Export, SVG Interactivity. +- **Impact:** settings that silently do nothing erode trust. +- **Estimate:** 13–20 h. + +## G2 Photoshop-style raster effects and the Effect Gallery + +- **Missing:** 37 of 57 filters (all of Artistic, Brush Strokes and Sketch), the Effect Gallery dialog, Load + Texture for Glass and Texturizer, Smart Blur's Edge Only and Overlay Edge. Measured in + [effects-parity.md](effects-parity.md). +- **Done:** the pixel-filter pipeline and 20 filters (Blur, Distort, Pixelate, Sharpen, Stylize, Texture, + Video), the last 13 on 2026-10-10. +- **Estimate:** 30–50 h (measured 0.7–1.2 h per filter); parallelizes well. + +## G13 Illustrator 2026 (30.x) additions + +- **Missing:** gradient dither and perceptual blending, gradient presets (30.0); tangent and perpendicular + snapping and the consolidated snapping options (30.0); artboard locking, artboard colours and managing + artboards from the canvas (30.0); the Blend panel with ease in/out and separate colour acceleration (30.7); + TIFF in Export for Screens (30.2); the Dimension tool (28.x). +- **Evidence:** Adobe's 30.0 and 30.7 release announcements, see + [target-app-parity.md](target-app-parity.md#how-this-was-measured-2026-10-10). +- **Estimate:** 15–25 h. + +## G14 Pen hardware beyond Windows pressure + +- **Missing:** pen pressure on macOS (#852) and Wayland (#491, #764) — winit 0.30 reports none there; tilt, + bearing and barrel rotation for brushes (6D Art Pen, #372); the eraser end. +- **Estimate:** 8–16 h, partly waiting on winit 0.31; needs pen hardware to verify. + +## G5 Brushes, symbols and libraries + +- **Missing:** the Art brush's Overlap, auto-generated Pattern brush corners, new tile art in Pattern Brush + Options, the Key Color eyedropper; original brush, symbol and graphic-style libraries generated in code (never + Adobe's); dynamic symbols; Start Global Edit. +- **Estimate:** 17–28 h. + +## G3 3D and Materials + +- **Done:** an initial live Revolve in the `three-d` crate (#846, from the contributor who offered in #605). +- **Missing:** Extrude & Bevel, Inflate and Rotate, caps, materials and better lighting, exact visibility for + intersecting surfaces, 3D Classic, Turntable, 3D export (OBJ, USDA, glTF). +- **Impact:** specialist, but a visible menu of stubs. +- **Estimate:** 45–75 h, the largest single gap; plan in `plan/` before coding. + +## G7 Views and windows + +- **Missing:** multiple windows and Window › Arrange (Cascade, Tile, Float in Window, Consolidate All Windows), + panels on another monitor, New View/Edit Views, global and video rulers. +- **Estimate:** 15–25 h. + +## G6 Automation and scripting + +- **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, image and graph + variable kinds, dataset import/export. Actions, Variables (text and visibility) and full agent control over + MCP are done. +- **Estimate:** 8–14 h. + +## G15 Performance budgets and a GPU renderer + +- **Missing:** an idle-machine run of `vectorcraft-cli perf` (last: 2026-10-01 on a loaded machine, 290 ms for + a 50k-path fit against a 16 ms budget), fixes for any budget missed, dirty-region rendering, the GPU backend + spike (M5). +- **Estimate:** 15–25 h (the budget run and fixes block beta). + +## G16 Localization: the five missing key languages + +- **Missing:** Hindi, Arabic, Indonesian, Korean, Vietnamese catalogs; a right-to-left interface and interface + shaping for Arabic and Devanagari; messages for Simplified Chinese and Brazilian Portuguese; native review of + every catalog. Measured in [localization-parity.md](localization-parity.md). +- **Estimate:** 70–110 h plus human review. + +## G17 Ecosystem: third-party plug-ins + +- **Missing:** Illustrator's ecosystem is a C++ plug-in SDK and a large commercial plug-in market. VectorCraft + has sandboxed WebAssembly plug-ins for object filters and live effects ([plugins.md](plugins.md)) and MCP; + missing are tool plug-ins, panel plug-ins, a plug-in directory and any third-party plug-ins (#710). +- **Estimate:** 20–45 h for the APIs; the ecosystem itself isn't agent work. + +## G18 Generative AI features + +- **Missing:** Text to Vector Graphic, Generative Recolor, Generative Shape Fill (#766), Generative Expand, + Concept to Vector, Prompt to Edit, Rewrite Text. Adobe's run on its cloud models; ours would be a pluggable + provider. +- **Needs:** an owner decision on models and providers. +- **Estimate:** 30–60 h after that decision. + +## G19 Accessibility + +- **Missing:** AccessKit is on by default, so egui widgets reach screen readers, but custom panels, the canvas and its objects aren't described and nothing has been tested with VoiceOver, Narrator or Orca; keyboard-only canvas operation; high + contrast. Illustrator's own accessibility is limited, so this is mostly beyond parity. +- **Estimate:** 15–25 h. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | G3: initial Revolve landed (#846), 45–75 h left | +| 2026-10-10 | minor | G9 evidence counted from the tracker (28 `.ai`/EPS issues, 20 fixed); impact narrowed to handing files back | +| 2026-10-10 | minor | Alpha blockers checked against the core-workflow gate: none | +| 2026-10-10 | major | Moved from the ROADMAP's "Where we're lacking"; kept ids G1–G8, added G9–G19 (file formats, stability, bugs, preferences, 30.x additions, pen hardware, performance, localization, ecosystem, AI, accessibility) and re-ranked | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 000000000..cc8039e85 --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,36 @@ +# Hardware parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist) · **Target:** Adobe Illustrator 2026 (30.x) + +What hardware Illustrator uses, per platform, against VectorCraft. Illustrator's side is from its public +documentation (GPU Performance, touch workspace, pen pressure pages); ours from the code +(`crates/ui-egui/src/canvas.rs`, `touch.rs`, `graphics.rs`, `crates/render`). Part of +[target-app-parity.md](target-app-parity.md). + +## Summary + +**~35% (estimated), 25–45 h.** VectorCraft rasterizes the canvas on the CPU (multithreaded `vello_cpu`) and uses +the GPU only to composite, so any GPU that can show a window works, at the cost of GPU-accelerated preview +on huge documents. Pen input is pressure-only and Windows-first. + +| Feature | Illustrator | VectorCraft macOS | Windows | Linux | Web | Status / hours | +|---|---|---|---|---|---|---| +| GPU-accelerated canvas (GPU Performance, GPU Preview, GPU anti-aliasing) | Metal, DirectX | CPU raster, GPU composite (wgpu/Metal) | CPU raster, GPU composite (DX12, OpenGL fallback) | CPU raster, GPU composite (Vulkan/GL) | WebGPU, WebGL2 fallback | partial; a GPU renderer spike is pending (M5): 15–25 h | +| Multithreaded rendering off the UI thread | partial | yes | yes | yes | single-threaded | beyond Illustrator | +| Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done | +| Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done | +| Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | **no** (winit reports none, #852) | yes (pointer force via `WM_POINTER`) | X11 only via XWayland; none on Wayland (#491, #764) | browser pointer events | partial: 4–8 h for macOS and Wayland (winit 0.31) | +| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | no | no | no | no | missing: 4–8 h after the input layer (#372) | +| Pen eraser end | yes | no | no | no | no | missing | +| Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch and scroll | pinch, pan; two-finger tap undo, three-finger redo (#585) | same where winit reports touch | same | partial: touch workspace and rotate gesture missing, 3–5 h | +| Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | +| HiDPI / Retina, per-monitor scale | yes | yes | yes | yes | yes | done | +| Multiple monitors: panels and document windows on another screen | yes | no (one OS window) | no | no | — | missing: 6–10 h (with multiple windows, gap G7) | +| Display colour profile (proof and display through the monitor's ICC) | yes | no | no | no | — | missing: 3–5 h | +| 3D GPU rendering (Materials, ray tracing) | yes | — | — | — | — | blocked on 3D (gap G3) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First checklist from the canvas, touch and graphics code and the open hardware issues | diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 000000000..c7123a1e4 --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,66 @@ +# Localization parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language table, measured from the catalogs) · **Target:** Adobe Illustrator 2026 (30.x) + +Per-language status of VectorCraft's interface. How the catalogs work and how to add a language: +[`development.md` › Localisation](development.md#localisation). Part of +[target-app-parity.md](target-app-parity.md). + +**How it is measured:** rows with a translation in each `crates/ui-egui/src/i18n/.tsv`, counted on +origin/main `01e165af`. The reference is the French catalog, which the tests hold complete: 3,416 interface +strings (menus, command labels, `tl!` literals: panels, dialogs, tooltips, Preferences) plus 604 `@msg` status and +error messages, 4,020 in all. A language's % is its rows over 4,020 (capped at 100% per part). The +`complete_menus` flag and `COMPLETE_MESSAGES` list make `i18n::tests` fail if a complete language misses a +string, so "full" here is enforced by tests. No language has help content: there is no translated (or English) +in-app help, and the docs are English. + +**Native review:** every catalog was written clean-room from the English meaning, most of it by agents +(`bflatastic`), with community contributors adding or correcting (German by `dwetscher`, Japanese and Traditional +Chinese by `imachus`). None is recorded as reviewed by a native speaker; the catalog headers ask for one. + +## The twelve key languages + +| Language | Code | UI strings translated | Dialogs / tooltips / messages | Script support | Native review | Status | To `full` (h) | +|---|---|---|---|---|---|---|---| +| English | `en` | source, 100% | all | Latin | — | full | — | +| Simplified Chinese (Mandarin) | `zh-hans` | 3,647 rows; menus, panels, dialogs complete (tests); 85% overall | messages in English (1 of 604) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | partial | 2–3 | +| Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | +| Hindi | `hi` | 0 | none | Devanagari: the document's text shapes (HarfRust), untested for Devanagari, but the egui interface does no complex shaping | no | none | 20–30 | +| Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | +| French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) | +| Portuguese | `pt-br` | 2,726 / 4,020 (68%): every menu label and `tl!` literal | messages in English | Latin | no | partial | 4–6 | +| Indonesian | `id` | 0 | none | Latin | no | none | 5–8 | +| Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | +| German | `de` | 3,952 / 4,020 (98%; tests hold menus, literals and messages complete) | all (602 of 604 message rows; landed 2026-10-10) | Latin | contributor-written, not reviewed | full | — (review) | +| Korean | `ko` | 0 | none | Hangul needs a UI font in craft-fonts; the IME path exists | no | none | 6–10 | +| Vietnamese | `vi` | 0 | none | Latin with stacked diacritics (precomposed forms render) | no | none | 5–8 | + +**12-language score: ~54%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 68 + 0 + 100 + 98 + 0 + 0, over 12). +**To `full` for all twelve: ~70–110 h**, most of it Arabic (right-to-left interface) and Hindi (interface +shaping). Each new Latin-script catalog is ~4,000 rows; the existing ones were written in a few agent hours each +plus review. Native-speaker review is human work and isn't in the hours. + +## Other shipped languages + +| Language | Code | Translated | Status | +|---|---|---|---| +| Traditional Chinese (Taiwan) | `zh-hant` | 3,425 rows, menus and literals complete; messages in English (80% overall) | partial | +| Italian | `it` | 4,011 / 4,020 | full | +| Russian | `ru` | 4,009 / 4,020 | full | +| Ukrainian | `uk` | 4,009 / 4,020 (one/few/many plurals) | full | +| Czech | `cs` | 679 / 4,020 (17%): every menu label | menus only | + +Eleven non-English catalogs in all. Illustrator, as its public documentation lists, ships around two dozen +interface languages (including Korean, Polish, Dutch, Turkish, the Nordic languages, Hungarian, and Middle East +editions with Arabic and Hebrew); it doesn't ship Hindi, Indonesian or Vietnamese. + +## Not done in any language + +Locale-aware number and date formats, a right-to-left interface, translated help, and a Traditional Chinese UI +font for the web build. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First per-language table, measured from the catalog row counts against the complete French catalog | diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 000000000..749f5b70e --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,84 @@ +# Roadmap: milestones and what's next + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (added the Alpha gate table; earlier the same day: moved the milestone table and direction here from ROADMAP.md, added Current focus) · **Target:** Adobe Illustrator 2026 (30.x) + +Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and +[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 +agent wall-clock hours, one agent. There is no `cargo xtask scorecard` or `cargo xtask parity` in this repo yet +(the clean-room rule rules out a menu dump of Illustrator as an oracle; a checklist built from Illustrator's +public menu documentation would be allowed). + +## Alpha gate + +The core-workflow gate from craftrules' progress-docs standard: what a typical professional does with +Illustrator every day, checked end to end on the main platform (macOS) with save and reopen in the native +`.vectorcraft` format (lossless, property-tested round trips in `crates/format/tests/prop_format.rs`). +Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so VectorCraft passes and stays alpha. + +| Core workflow | Works end to end? | Evidence | Hours to pass | +|---|---|---|---| +| Draw and edit paths precisely: Pen, Curvature, Direct Selection on anchors and handles, smart guides and snapping, Pathfinder | yes | `crates/tools/src/pen.rs`, `direct.rs`, `guides.rs` and their tests; 10 exact Pathfinder booleans; [ui-parity.md](ui-parity.md). Shape Builder misses regions in some files (#937, #893), Pathfinder covers the same results | 0 | +| Paint and style: fills and strokes, global and spot swatches, gradients, Appearance, transparency, graphic styles | yes | M3 done (92 tasks); area scores 92–97% in [target-app-parity.md](target-app-parity.md#feature-areas) | 0 | +| Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); hyphenation and justification options missing (not blocking) | 0 | +| Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; ghost borders in some Asset Export output (#983) | 0 | +| Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | +| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951, #868) and lose guides (#779); Save As `.ai` is PDF-compatible, which Illustrator opens without its own editing data. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | + +## Current focus + +1. **Interaction-fidelity pass** (G1) by tool, from [ui-parity.md](ui-parity.md): Pen and Curvature, Direct + Selection, smart guides and snapping first. 60–90 h, parallel by tool. +2. **`.ai` files from real users** (G9): layer structure (#951, #868), guides (#779). 10–20 h. +3. **Launch and stability** (G10): the Windows and Linux start-up reports. 20–35 h. +4. **Core-tool bugs** (G11): Shape Builder regions (#937, #893), Asset Export borders (#983). 8–15 h. +5. **Raster effects** (G2) continue alongside: Artistic, Brush Strokes, Sketch, then the Effect Gallery. 30–50 h. + +## Next + +- **Then:** advanced type (G4) and the Illustrator 2026 additions (G13), then 3D (G3), each with a plan in + `plan/` before coding. +- **Alongside:** close the 28 stubbed menu items; run `vectorcraft-cli perf` on an idle machine and fix any + budget missed (G15). +- **Beta:** G1, G9, G10, G11, G12, the corpus part of G8, type core, macOS pen pressure and the perf budgets + (~170–270 h one agent, ~50–80 h with 4–6 agents). +- **1.0:** feature parity plus the fidelity pass, hardening and packaging for all three desktop OSes and the web. + +## Milestones + +| # | Milestone | Status | Est. remaining, one agent (h) | +|---|---|---|---| +| M0 | Skeleton + vertical slice | ✅ done | — | +| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | +| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | +| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | +| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | +| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | +| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 | +| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; graph Design/Column/Marker designs pending | 6–10 | +| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | +| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 | +| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 | +| | **Total to full parity** (one agent; from [target-app-parity.md](target-app-parity.md)) | | **~360–590** | + +Milestone estimates predate the 2026-10-10 re-measure for M1–M14; the area table in +[target-app-parity.md](target-app-parity.md) is authoritative when they disagree. + +## Progress log before the progress-docs standard + +The ROADMAP's "last updated" line as of 2026-10-10, kept as the record of what landed up to then: + +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 window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2), Variables (data merge): text and visibility variables, data sets and the Variables panel (#761), a window whose first frame never reaches the screen starting again on the next graphics processor (#964)). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | M9 row from main: initial Revolve | +| 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha | +| 2026-10-10 | major | Created from ROADMAP.md's milestone table and "Where we're going"; added Current focus and the beta list | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md new file mode 100644 index 000000000..a3d1de2a4 --- /dev/null +++ b/docs/target-app-parity.md @@ -0,0 +1,462 @@ +# Parity with Adobe Illustrator + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) + +The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is +the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), +[file formats](file-format-parity.md), [hardware](hardware-parity.md), [languages](localization-parity.md), +[effects](effects-parity.md) and [type](type-parity.md). + +## Headline + +| Number | Value | Kind | +|---|---|---| +| **Feature breadth** (does each Illustrator menu item, tool, effect and format exist?) | **~88%** | measured in part: menu tree 348 of 375 items wired (92.8%), tools 89 of 92 (97%), vector effects 44 of 50 (88%), Photoshop-style effects 20 of 57 (35%), 3D 1 of 4 (initial Revolve); blended by the area weights below | +| **Feature depth** (each area scored by behaviour, weighted) | **~75%** | estimated, area table below | +| **Ready for real work, mainstream illustrator** | **~65%** (60–70%) | estimated, see [Ready for real work](#ready-for-real-work) | +| **Ready for real work, full Illustrator** (decides the stage) | **~62%** (57–67%) | estimated: additive weighted sum over the dimensions, see [Ready for real work](#ready-for-real-work) | +| **Ready for real work, essentials user** (core features only, see [Essentials user](#essentials-user)) | **~75%** (70–80%) | estimated | +| **Remaining to beta** | **~170–270 h** one agent | estimated, [gaps.md](gaps.md) beta list | +| **Remaining to full parity** | **~360–590 h** one agent · ~95–165 h wall clock with 4–6 agents | estimated, sum of the area table + fidelity pass + hardening | + +All hours are **Opus 5.5 agent wall-clock hours**: one agent session working sequentially, including builds, +tests and the CI gate. See [Calibration](#calibration). + +## How this was measured (2026-10-10) + +- **Target version.** Illustrator 2026, the 30.x line. Feature set from Adobe's public documentation and release + announcements: 30.0 (October 2025: font browser, gradient dither and perceptual blending, tangent and + perpendicular snapping, artboard locking and colours, Turntable beta, Firefly integration), 30.2 (TIFF in + Export for Screens), 30.3, 30.5 (Concept to Vector, Prompt to Edit, Rewrite Text) and 30.7 (Blend panel with + ease in/out). Sources: the [30.0 announcement](https://community.adobe.com/questions-652/illustrator-2026-v30-0-font-browser-color-snapping-artboards-enhancements-turntable-and-more-1546612), + the [30.7 announcement](https://community.adobe.com/announcements-651/illustrator-30-7-new-blend-panel-ease-in-out-blends-generate-artwork-with-firefly-bug-fixes-and-more-1635245) + and helpx feature pages. +- **The installed Illustrator was not inspected.** Illustrator 2026 is installed on the owner's Mac, but this + repo's clean-room rule ([`AGENTS.md`](../AGENTS.md), from the project owner) forbids reading anything inside an + Adobe install, its `Info.plist` and `.lproj` folders included, and forbids launching or measuring it. So + nothing here is a menu dump or a side-by-side measurement: the numbers come from our own code against + Illustrator's public documentation. +- **From the code (measured, origin/main `01e165af`):** + - Menu tree (`crates/ui-egui/src/menus.rs`, laid out in Illustrator's menu order): 347 items bound to + commands, 27 stubs (`todo(…)`/`todos(…)`), so 92.8% of menu items do something (348 and 27 after the + Revolve merge). The stubs: Effect › 3D and Materials (4), Effect › SVG Filters, Edit › Spelling (3), Graph › Design/Column/Marker (3), Live Paint + Gap Options, Optical Margin Alignment, Start Global Edit, global and video rulers (2), Snap to Glyph, + Window › Arrange (4), SVG Interactivity, the library placeholders (3), Other Script…, Help. + - Engine commands: ~710 ids registered with `cmd!` across `crates/engine/src/cmd` (plus macro-generated + families), and 85 UI-only commands (`UI_COMMANDS`). + - Tools: 91 in `crates/tools/src/catalog.rs`; Touch Type is listed but not implemented, and the Dimension + tool (added in Illustrator 28.x) is absent. + - Effects catalogue (`crates/effects/src/lib.rs`): see [effects-parity.md](effects-parity.md). + - Tests: 4,924 `#[test]` functions plus 40 property-test blocks (was "~2,840" in the last estimate). + ~354k lines of Rust (was ~221k on 2026-10-07). + - Formats: the `FORMATS` table in `crates/engine/src/cmd/fileio/mod.rs`, see + [file-format-parity.md](file-format-parity.md). + - Languages: row counts of `crates/ui-egui/src/i18n/*.tsv`, see [localization-parity.md](localization-parity.md). +- **From users:** the 71 open GitHub issues on 2026-10-10, which show what real users hit first (Illustrator + `.ai` files opening with the wrong layer structure #951/#868, guides lost from `.ai` #779, Shape Builder + region detection #937/#893, launch failures on some Windows and Linux machines #858/#964/#620, pen pressure on + macOS #852, Preferences options that do nothing #394). +- **Presence is not parity.** Each area is scored by behaviour against the public documentation, and an area is + 100% only when every feature in it behaves like Illustrator. Scores were set by the agents that built the + features, so they are upper bounds; this pass lowered the ones contradicted by open issues. + +## Feature areas + +Weights are the share of Illustrator users' everyday work each area represents (drawing, paths, paint and type +dominate; 3D, graphs and automation are specialist). They sum to 105. Feature depth = Σ weight × score / Σ weight. + +| Area | Weight | Done | Main gaps | One agent (h) | Kind | +|---|---:|---:|---|---:|---| +| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances | 4–6 | estimated | +| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool, Live Corners on curved corners; see [ui-parity.md](ui-parity.md) | 8–13 | estimated | +| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937, #893), its gap detection and options; Live Paint Gap Options | 4–8 | estimated (lowered from 86%: open issues) | +| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither, perceptual blending and gradient presets; freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated (lowered from 96%: target moved) | +| Strokes, brushes, width profiles | 5 | 88% | Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated | +| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated | +| Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated | +| Raster effects (Effect Gallery) | 4 | 40% | 37 Photoshop-style filters (Artistic, Brush Strokes, Sketch), the Effect Gallery dialog, Load Texture | 30–50 | measured count 20/57 | +| 3D and Materials (and Turntable) | 4 | ~8% | initial live Revolve done (flat shading, one light, no caps, approximate visibility for intersecting surfaces); Extrude & Bevel, Inflate, Rotate, materials, Turntable views, 3D export | 50–85 | estimated (Revolve landed after the first measure, #846) | +| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, see [type-parity.md](type-parity.md) | 14–19 | estimated | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi, proportional vertical metrics), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 85% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape | 6–10 | estimated (lowered from 88%: target moved) | +| Image Trace, graphs, image tools | 3 | 75% | graph Design/Column/Marker, Image Trace Create Strokes depth (Create Object Mosaic is done) | 5–8 | estimated (raised from 72%) | +| Layers, artboards, document setup | 5 | 78% | 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export | 5–8 | estimated (lowered from 82%: target moved) | +| View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange, Snap to Glyph | 9–14 | estimated | +| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides, global/video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved) | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951, #868), guides lost (#779), no writing of Illustrator's editing data, FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md) | 10–20 | estimated (lowered from 85%: open issues) | +| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983), TIFF in Export for Screens (30.2) | 2–4 | estimated | +| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated | +| Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated | +| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, ~17 Preferences options stored but unused (#394), panels outside the window, SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated | +| Libraries, Links, Package | 2 | 82% | shared libraries, linked graphics, gradients and patterns as library items (no cloud by design) | 2–3 | estimated | +| Generative AI (Text to Vector, Generative Recolor, Shape Fill, Concept to Vector, Prompt to Edit, Rewrite Text) | 2 | 0% | a pluggable provider and the panels; needs an owner decision on models | 30–60 | measured (absent) | +| **Feature depth** | **105** | **~75%** | | **~250–420** | | +| Interaction-fidelity pass (every tool's modifiers, cursors, Properties per context) | | | [ui-parity.md](ui-parity.md) | 60–90 | estimated | +| Hardening (real-file corpus, cross-platform and browser QA, launch failures, performance budgets) | | | [gaps.md](gaps.md) | 50–80 | estimated | +| **Full parity** | | **~55%** | | **~360–590** | | + +Excluding the generative-AI row (the old table had none), depth is ~76% and the remaining feature work +~220–360 h. With 4–6 agents on disjoint crates, wall-clock time divides by about 3.5–4 (shared files such as +`menus.rs` and the i18n catalogs serialize some work): ~95–165 h to full parity. + +**Why the totals moved up from ~320–500 h (2026-10-05):** the target moved (30.x added gradient dither, +perceptual blending, new snaps, artboard locking, the Blend panel, Turntable), open issues lowered three areas, +the raster-effects row was re-counted bottom-up from measured filter throughput, and generative AI is now +counted. Work that landed since (Variables, Distort/Pixelate/Texture filters, Object Mosaic, German, placed +documents) moved other rows up. + +## Ready for real work + +Three audiences, each with its own readiness and its own hours to ~95%. Essentials ⊂ mainstream ⊂ full, so the +hours nest. Hours are calibrated as in [Calibration](#calibration). They divide by about 3.5–4 across 4–6 agents +for the feature and fidelity work; stability on users' hardware, the real-file corpus and native-language +review parallelize less and need humans. + +| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates | +|---|---:|---:|---:|---| +| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (30–50 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem | +| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas | +| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish | + +### Full Illustrator: additive weighted sum over the dimensions + +The standard's method, the same in every app: a weighted **sum** over the dimensions, with written weights. +Values are from [By dimension](#by-dimension). + +| Dimension | Weight | Value | Contribution | +|---|---:|---:|---:| +| Features (depth) | 35 | 75% | 26.3 | +| UI/UX fidelity | 15 | 55% | 8.3 | +| File formats | 12 | 75% | 9.0 | +| Stability | 10 | 60% | 6.0 | +| Performance | 8 | 60% | 4.8 | +| Platforms | 5 | 80% | 4.0 | +| Hardware | 4 | 35% | 1.4 | +| Localization | 4 | 54% | 2.2 | +| Ecosystem and plug-ins | 4 | 15% | 0.6 | +| AI features | 3 | 0% | 0.0 | +| **Ready for real work** | **100** | | **~62%** | + +The weights put most of the number on what professionals use daily (features, interaction, files, stability). +Generative AI also sits inside the features row at weight 2 of 105, so it is counted slightly twice; that costs +under a point. + +### Mainstream illustrator and essentials user + +Both start from feature depth over the areas concerned. **Mainstream:** ~83% over 16 areas (weight 83 of 105: +selection, drawing, path operations, colour, strokes, appearance, live effects, type core, symbols and blends, +layers and artboards, view, guides, file formats, Export for Screens, UI chrome, print). That is discounted +×0.9 for interaction fidelity, ×0.92 for stability on real machines and ×0.92 for Illustrator-file exchange +(83 × 0.9 × 0.92 × 0.92 ≈ 63). The dimensions a typical illustrator rarely hits (plug-in ecosystem, +generative AI, pen tilt, languages beyond their own) are left out. **Essentials:** see [Essentials user](#essentials-user). + +The discounts, with the evidence: + +- **Illustrator's own files (×0.92).** `.ai` and EPS open as editable art by reading their PDF/PostScript and the + structure of the editing data. Users do open legacy files a lot: 28 of the 350 issues (8%, ~22 people) are + about `.ai`/EPS, the most common single format theme. 20 of the 28 are fixed (often the same day). The 8 open + ones are about structure, not missing art: layers flattened or multiplied (#951: 109 layers instead of 14; + #868), guides lost (#779), slower opening (#758), live effects, brushes and symbols as their drawn look + (#637). VectorCraft can't write Illustrator's editing data (undocumented), so its `.ai` opens in Illustrator + as plain art. That blocks beta (the main-format rule) for people who hand files back to Illustrator users, + but it doesn't stop someone opening and reworking their own archive. +- **Interaction fidelity (×0.9).** Pen, Direct Selection, smart guides and snapping are deep + ([ui-parity.md](ui-parity.md)), and there has never been a side-by-side session. The issue tracker says the + details mostly hold: 79 issues are about core drawing (pen, paths, selection, snapping, transform), 68 of them + (86%) closed, 11 open (#991, #975, #955, #973 among them). One practitioner wrote "0 learning curve from + pretty heavy AI use over last 20+ years" (#541). +- **Stability on real machines (×0.92).** The code never panics by design, and the main platform (macOS) starts + reliably, but 26 issues mention a crash, freeze or failed launch (8 open: #858, #964 fixed the day of this + review, #620, #818, #905 on Windows 7, #834). There is no field record and no real-world file corpus yet. +- **Performance unverified.** Budgets (`vectorcraft-cli perf`) haven't been run on an idle machine since + 2026-10-01; the last loaded run missed the 16 ms frame budget at 50k paths. Not discounted separately: no + user has reported slowness in mainstream work beyond #575 (Linux AppImage) and #758 (`.ai` open time). +- **Missing subsystems** a minority need daily (3D, the Effect Gallery, CJK vertical composition, scripting) + pull the full number below the mainstream one. + +### Essentials user + +A beginner or casual user who touches only the core features, at their default settings. Excluded: advanced +options, pro workflows, file-exchange edge cases, and everything the mainstream number already leaves out. +Depth here is "does the basic use work", not the area's full score. + +| Core feature | Weight | Depth | Evidence | +|---|---:|---:|---| +| New, open and save documents | 8 | 95% | New Document presets, Open for SVG/PDF/`.ai`/images, lossless native save, Data Recovery | +| Shape tools (rectangle, ellipse, polygon, star, line) | 9 | 95% | all shape tools, Live Corners | +| Selection, move, scale, rotate | 9 | 92% | bounding box, smart guides, transform tools | +| Pen and Pencil, basic use | 7 | 88% | [ui-parity.md](ui-parity.md); a few Pencil and Curvature options missing | +| Fill, stroke, colour, swatches | 9 | 95% | M3 done | +| Gradients, basic | 4 | 90% | Gradient panel and on-canvas annotator | +| Type: point and area text, font, size | 8 | 88% | font menu with previews; hyphenation options missing (rarely touched) | +| Align, group, arrange | 6 | 95% | | +| Layers, basic | 4 | 85% | Layers panel rework; no row context menus | +| Shape Builder | 5 | 70% | beginners lean on it; region detection fails in some files (#937, #893) | +| Pathfinder | 4 | 95% | exact booleans | +| Place images, Image Trace | 4 | 85% | 13 trace presets | +| Export PNG/JPG/SVG/PDF, print | 8 | 88% | ghost borders in some Asset Export output (#983) | +| Undo and redo | 4 | 100% | unlimited, property-tested | +| Zoom, pan, fit | 4 | 95% | | +| Artboards, basic | 4 | 88% | | +| **Weighted depth** | **97** | **~91%** | | + +The discounts a beginner actually hits: + +- **Launch and stability, ×0.93:** fine on macOS, but some Windows machines fail to start (#858, #620, #818). +- **Discoverability and UI clarity, ×0.92:** the layout follows Illustrator's and comes in 11 languages, but + there is no in-app help (Help › VectorCraft Help… is a stub), no Learn panel or tutorials, and a newcomer + meets Illustrator's density without Illustrator's learning material. +- **Opening files people send them, ×0.96:** PDF, SVG, images and most `.ai` files open as editable art. + Layer-structure problems (#951) matter less to a beginner. + +91 × 0.93 × 0.92 × 0.96 ≈ 75: **~75% (70–80%), estimated.** + +### User sentiment (measured 2026-10-10) + +All 350 issues and 964 issue comments on GitHub (Discussions are off), authors other than the maintainers: + +- **Praise:** 17 issue threads from 15 people ("Great work on this. It's kind of amazing" #954; "really great + job" #841; "I really love this project" #620; "0 learning curve from pretty heavy AI use over last 20+ years" + #541). +- **"Replacing Illustrator" reports:** none found in the tracker. Praise is for the app as it is, not reports + of having switched on paid work. Owner reports from practitioners outside GitHub aren't counted here. +- **Themes of the 350 issues:** core drawing 79 (11 open), import/export 59 (17 open), launch/crash/platform/ + performance 51 (12 open), UI chrome and layers 46 (10 open), type 28 (4 open), colour 12 (5 open), other and + requests 75 (24 open). 35 are titled as feature requests. The open set is mostly core-path bugs and + file-exchange problems, not niche features. + +**Stage: alpha.** All six core workflows pass the core-workflow gate end to end on macOS with save and reopen +([roadmap.md › Alpha gate](roadmap.md#alpha-gate)); file exchange with Illustrator users is partial, which is +what keeps it from beta, not from alpha. + +## By dimension + +| Dimension | % | One agent (h) | Kind | Doc | +|---|---:|---:|---|---| +| Features (depth, weighted) | 75% | 220–360 | estimated | this file | +| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md) | +| File formats | 75% | 15–30 | estimated | [file-format-parity.md](file-format-parity.md) | +| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | estimated | [hardware-parity.md](hardware-parity.md) | +| Localization (the 12 key languages, equally weighted) | 54% | 70–110 | measured from catalogs | [localization-parity.md](localization-parity.md) | +| Performance | ~60% | 15–25 | estimated (unverified budgets) | [gaps.md](gaps.md) | +| Stability | ~60% | 30–50 | estimated | [gaps.md](gaps.md) | +| Platforms (macOS, Windows x64/arm64, Linux, FreeBSD, web) | 80% | 15–25 | estimated | [gaps.md](gaps.md) | +| Ecosystem and plug-ins (scripting, third-party plug-ins) | 15% | 30–60 | estimated | [gaps.md](gaps.md) | +| AI features (generative) | 0% | 30–60 (+ owner decision) | measured (absent) | [gaps.md](gaps.md) | +| Agent automation (MCP, CLI, control channel) | beyond Illustrator | — | measured | [mcp.md](mcp.md) | + +The dimension hours overlap (the feature rows already hold some UI, format and AI work), so they don't add up +to the full-parity total. + +## Calibration + +Hours come from this repo's own history (git log and merged-PR timestamps, `gh pr list`): + +- **Raster filters**, one agent working serially on [gap 2](gaps.md): Pixelate (4 filters, merged 2026-10-10 + 08:47), Texture (6, 12:44), Distort (3, 16:24). About 4 h per batch, **0.7–1.2 h per Photoshop-style + filter** including dialog, catalogue row, menus and translations into 11 languages. +- **M3 (paint and appearance):** a 92-task plan whose packages were planned at 5–11 h each took about + 1–1.5 agent hours each, on 2026-10-04/05 with 6–8 agents. +- **Medium features:** about 0.8 h each (Text Wrap, Graphs, Effect › Pathfinder…: ~20 in ~16 h on 2026-10-02). +- **Whole repo:** 1,671 commits and 592 merged PRs from 2026-09-30 to 2026-10-10, with several agents in + parallel and a dozen community contributors. + +Large subsystems (3D, CJK composition, generative AI) have no comparable history and are counted bottom-up, +with a wider range. What needs a human: native-speaker review of the translations, an owner decision on +generative models, real Illustrator files that users own (used locally, never committed), and Wacom/pen +hardware for tilt and rotation. + +## Where we already beat Illustrator + +Exact curve booleans, off-thread multithreaded rendering, unlimited undo, lossless SVG/PDF export of live effects, +a documented JSON format, Affinity 1–3 import, the same app on the web and on Linux/FreeBSD, scrubby numeric fields, +sandboxed WebAssembly plug-ins, and every command, gesture and dialog drivable by agents (MCP, CLI, control channel). + +## Out of scope (by design or by law) + +- **Adobe cloud services** (Libraries sync, Adobe Fonts, Projects, Express templates, Firefly): pluggable + provider APIs at most, never built in. +- **Adobe's bundled assets** (swatch, brush, symbol libraries, presets, icons): ours are original. +- **Illustrator's private editing data, beyond its structure:** see + [file-format-parity.md](file-format-parity.md#illustrators-private-editing-data). + +## Detailed missing items per area (2026-10-05 table, still current unless the area table above says otherwise) + +The previous estimate's per-area missing lists, kept as evidence. Scores in it are superseded by the table above. + +_Method (2026-10-02; M3 rows re-derived 2026-10-04, file, export, print, links and UI-chrome rows after M4 on 2026-10-05):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of +real users' work) each represents. Each area is scored by depth of behaviour, not by presence of a menu item: an area +is 100% only when every feature in it behaves like Illustrator. Feature parity = Σ weight × score / Σ weight. Remaining +time is counted per area from what is missing, calibrated on measured throughput: in the last session one agent landed +about 20 medium features (Text Wrap, Graphs, Effect → Pathfinder, export baking…) in ~16 h wall clock including builds +and CI on a heavily loaded machine — about 0.8 h per medium feature; large subsystems (3D, raster filters, vertical type) +are counted bottom-up. The M3 rows (colour, strokes, appearance) come from a detailed 92-task M3 plan whose review +found more missing depth than the first estimate; all 92 tasks landed on 2026-10-04 and 2026-10-05 with 6–8 agents in parallel; its packages, planned at 5–11 h each, took +about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) are likely high. + +| Area | Weight | Done | Missing (main items) | One agent (h) | +|---|---:|---:|---|---:| +| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 | +| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 | +| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | +| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | +| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 | +| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | +| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 | +| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 | +| 3D and Materials | 4 | ~8% | Initial Revolve only, with simple lighting and Revolve-specific visible-surface expansion; Extrude & Bevel, Inflate, Rotate, caps, materials, shadows and more complete visibility remain | 45–75 | +| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 | +| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | +| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), 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 (Move Artwork for artboard X/Y edits, 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; 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 (Photoshop Import Options; the merged image is done), 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 | +| Automation | 3 | 65% | scripting surface, batch (Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | +| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 13–20 | +| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 | +| **Feature parity** | **103** | **~75%** | | **~210–330** | +| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 | +| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 | +| **Full parity** | | **~56%** | | **~320–500** | + +_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool +implemented except Touch Type; 51 panel modules; Illustrator-style live +effects ~44/54, Photoshop-style raster effects ~4/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust. + +## Honest assessment by dimension (2026-10-05) + +The previous assessment's evidence per dimension, kept as it was; the [By dimension](#by-dimension) table above supersedes its numbers. + +**In one line:** about two-thirds to three-quarters of Illustrator's features exist and work, and for everyday +vector illustration (drawing, paths, paint and appearance, SVG/PDF/print) VectorCraft is close to usable. Measured +against "a power user can't tell the difference", it is closer to 40–55%. What stands between here and "anyone can +switch" is a handful of large subsystems plus a fidelity pass, not a long tail of small fixes. + +**How much to trust the numbers:** area scores are graded by the agents that built the features, from behaviour docs +(`plan/illustrator/`) and tests, not by side-by-side use of Illustrator. Treat them as upper bounds. Look & feel is +the only dimension measured against the reference app. Performance budgets haven't been re-run on an idle machine +since 2026-10-01. + + +| Dimension | Estimate | Evidence and what's missing | +|---|---|---| +| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | +| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) | +| **Large missing subsystems** | 0–20% | 3D & Materials (~8%, initial Revolve only), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting | +| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | +| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | +| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | +| **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 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; what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) | +| **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) | + +## Evidence: what ships today, by area + +Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands. + +- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. +- **Automation:** + - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. + - Variables (data merge): text and visibility variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility Dynamic, Capture/Update Data Set, and a row in italics when an edit leaves the art not matching it; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. + - 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. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832). + - 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`). 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. + - Hint bar, contextual task bar, 19 dock panels with ≡ menus. + - The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it). + - Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it. + - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels. + - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. + - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, German, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). + - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). + - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. + - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). + - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. + - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). + - Four brightness themes, persistent preferences. + - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). About 17 stored options still do nothing yet (the rest of #394: Highlight Alternate Glyphs, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace). + - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). + - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. + - Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off. + - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. + - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields. + - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). + - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. +- **Tools:** + - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain. + - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). + Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). + Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786). + - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. + - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. + - **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). + - **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). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph Design, Column and Marker artwork remain open. + - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. + - **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked. + - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. + - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. + - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel. +- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). +- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. +- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). +- **Geometry and effects:** + - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. + - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). + - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). +- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). +- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. +- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. +- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. +- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). +- **Paint and appearance (M3):** + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). + - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. + - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. + - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. + - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. + - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. + - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. + - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. +- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. +- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). +- **Files (M4):** + - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). + - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. + - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). + - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). + - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. + - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). + - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). +- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. +- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland. +- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Merged main: initial live Revolve landed (#846). 3D 0% → ~8% (50–85 h), menu stubs 28 → 27; depth, readiness and totals unchanged within rounding | +| 2026-10-10 | minor | Method aligned with the standard, no new evidence: the full ready-for-real-work number is the additive weighted sum over the dimensions (~62%). The multiplicative 55% from earlier today is withdrawn, and the 50% before it was an unbroken-down judgement. Added hours to ~95% for each audience (full 340–560 h, mainstream 230–380 h, essentials 60–105 h). Beta distance is now ~13 points | +| 2026-10-10 | minor | Added the essentials-user readiness score (~75%): 16 core features weighted, depth ~91%, discounted for launch, discoverability and files people send | +| 2026-10-10 | minor | Second look at ready for real work at the owner's request: 50% → 55% full Illustrator, and a new mainstream-illustrator figure of 65%. Why: the old 25-point discount was a judgement with no breakdown. Now each discount is written down and checked against the tracker: 86% of 79 core-drawing issues closed, 20 of 28 `.ai`/EPS issues fixed and the open ones about structure, praise from 15 people, no reports of switching; dimensions a typical illustrator rarely hits are taken out of readiness. Feature breadth, depth and hours unchanged | +| 2026-10-10 | minor | Stage checked against the core-workflow gate: passes, stays alpha | +| 2026-10-10 | major | Full re-measure against Illustrator 2026 30.x from code counts, open issues and public docs; added breadth/depth/ready-for-real-work numbers, generative-AI row, calibration from PR timestamps; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file | +| 2026-10-05 | major | (in ROADMAP.md) M3 and M4 rows re-derived after the paint and files milestones | +| 2026-10-02 | major | (in ROADMAP.md) First 22-area weighted estimate | diff --git a/docs/type-parity.md b/docs/type-parity.md new file mode 100644 index 000000000..f552e1a3b --- /dev/null +++ b/docs/type-parity.md @@ -0,0 +1,51 @@ +# Type parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, split out of the parity table's two type rows) · **Target:** Adobe Illustrator 2026 (30.x) + +Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in +[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the +code and tests (`crates/text`, `crates/engine/src/cmd/type*.rs`, `textstyles.rs`, `crates/tools/src/text.rs`) +against Illustrator's public documentation; **estimated** unless noted. + +## Summary + +| Part | % | Remaining (h) | +|---|---:|---:| +| Type core (point, area, path type, Character/Paragraph, styles, threading, OpenType, fonts) | 80% | 14–19 | +| Type advanced (CJK composition, Middle Eastern, spelling, Touch Type, Retype, variable fonts) | 50% | 20–28 | + +## Checklist + +| Feature | Status | Notes | +|---|---|---| +| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support | +| Area Type Options (rows, columns, inset, first baseline, Auto Size, Shrink Text to Fit) | done | | +| Threaded text, Fit Headline, Text Wrap | done | | +| Type on a Path effects and options; brackets | done | #429 | +| Character and Paragraph panels, styles with overrides | done | mixed-value display for differing paragraphs missing | +| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference unused (#394) | +| Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339 | +| Variable fonts | partial | named instances as styles; axis sliders missing | +| Colour fonts (OpenType SVG / COLR) | partial | | +| Every-line and Single-line composers | done | tuned by eye, never compared side by side | +| Hyphenation… and Justification… options | missing | | +| Optical Margin Alignment | missing | menu stub | +| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); leader drawing not verified against the docs | +| Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | +| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | +| Smart Punctuation, Change Case, Find & Replace | done | | +| Touch Type tool | missing | listed in the toolbar, not implemented | +| Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model | +| Snap to Glyph | missing | menu stub | +| Middle Eastern: bidirectional layout, Paragraph Direction | done | | +| Middle Eastern: Character Direction, digit types, kashidas, Middle Eastern composers, split caret | missing | | +| CJK: input methods (marked text, candidate window) | done | | +| CJK: kinsoku (Hard, Soft, None), Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, the Mojikumi Settings dialog and the other mojikumi sets missing | +| CJK: ruby, proportional vertical metrics (`vpal`/`palt`), manual tate-chu-yoko | missing | #966, #633 | +| Inline symbols in text | beyond Illustrator | not scored | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First checklist, split out of the parity table's Type core and Type advanced rows | diff --git a/docs/ui-parity.md b/docs/ui-parity.md new file mode 100644 index 000000000..ffe3bb2bf --- /dev/null +++ b/docs/ui-parity.md @@ -0,0 +1,167 @@ +# UI and interaction parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist: pen, anchors, handles, snapping, nudging, modifiers, panels) · **Target:** Adobe Illustrator 2026 (30.x) + +How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's +trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, +how far an arrow key nudges. This checklist is the work list for the **interaction-fidelity pass** +([gaps.md](gaps.md) G1). Part of [target-app-parity.md](target-app-parity.md). + +**How it is measured:** from the tool state machines' own documentation and tests (`crates/tools/src/*.rs`, +`crates/ui-egui/src/canvas.rs`, `shortcuts.rs`, the `tests_*` files) against Illustrator's public documentation +(helpx tool pages and the keyboard-shortcut reference). Never against the running app: the clean-room rule +forbids launching or measuring Illustrator, so there has been **no side-by-side session**. Every "done" below is +"behaves as the documentation describes", which is weaker than "a power user can't tell". Status: +**done** (documented behaviour implemented and tested), **partial**, **missing**. + +## Summary + +| Area | % | Remaining (h) | Kind | +|---|---:|---:|---| +| Pen, Curvature and anchor tools | 85% | 6–10 | estimated | +| Direct Selection, handles and anchors | 85% | 5–8 | estimated | +| Smart guides and snapping | 70% | 8–12 | estimated | +| Transform handles, bounding box, Free Transform | 80% | 5–8 | estimated | +| Nudging, numeric entry, precision, units | 85% | 3–5 | estimated | +| Modifier keys and temporary tools | 80% | 5–8 | estimated | +| Keyboard shortcuts (default set) | 70% | 6–10 | estimated | +| Cursors | 75% | 3–5 | estimated | +| Panels, Control bar, Properties, context menus | 70% | 15–25 | estimated | +| Look and feel (layout, theme, metrics) | 80% | 5–10 | measured once against public screenshots on 2026-10-02; panels added since not re-measured | +| Accessibility (screen reader, keyboard-only canvas) | 20% | 15–25 | estimated: AccessKit on by default for egui widgets; canvas and custom panels not described, never tested with a screen reader | +| **UI/UX overall** | **~55%** | **~70–110** (overlaps the feature areas) | estimated | + +The overall figure is lower than the rows because what users notice first (small differences across many +tools, never checked side by side) isn't captured row by row. + +## Pen, Curvature and anchor tools + +| Behaviour (Illustrator, public docs) | Status | Notes | +|---|---|---| +| Click: corner anchor; drag: smooth anchor with symmetric handles | done | `pen.rs` | +| Shift constrains segments and handles to 45° | done | Illustrator's Constrain Angle preference is honoured (`constrainAngle`) | +| Alt mid-drag splits the handles (only the outgoing follows) | done | keeps the curve already shaped | +| Space mid-drag repositions the anchor, handles and all | done | | +| Click the first anchor to close; the drag shapes the closing curve | done | | +| Click the last anchor to retract its outgoing handle; drag from it to pull a new one | done | | +| Alt over a handle, anchor or segment acts as the Anchor Point tool | done | | +| Cmd/Ctrl lends the last-used selection tool | done | the path being drawn continues afterwards | +| Click an open path's end to continue it; click another path's end to join | done | #776 | +| Rubber Band preview | done | Preferences option | +| Auto Add/Delete on a selected path; Shift or the preference disables it | done | | +| Pen cursors (new path ×, continue /, close o, add +, delete −, convert ^) | partial | vector cursors exist; not every state verified | +| Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` | +| Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | modifiers requested in #975 | +| Add/Delete/Anchor Point tools; Anchor Point tool drags segments to reshape | done | | +| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | partial | Keep selected and Alt to Smooth are missing | +| Join tool scrubbing, Average, Join with corner/smooth choice | done | | +| Simplify (slider, auto, corner angle threshold) | done | | + +## Direct Selection, anchors and handles + +| Behaviour | Status | Notes | +|---|---|---| +| Click/Shift-click anchors; marquee toggles with Shift | done | #483 | +| Drag a curved segment to bend it, a straight one to move its anchors | done | | +| Handles shown are the handles that drag; tolerance from Selection & Anchor Display | done | #494 | +| Drag a handle with Shift (45° about its anchor), Alt (move one handle alone) | done | handles snap to smart guides | +| Show handles when multiple anchors are selected; Highlight anchors on mouse over; anchor and handle size | done | Preferences › Selection & Anchor Display | +| Convert selected anchors to corner/smooth from the Control bar and Properties | done | | +| Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | | +| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | clicking a widget without pre-selecting the anchor (#908) missing | +| Corners between curved sides, Relative rounding | missing | | +| Isolation mode by double-click, breadcrumbs, Esc to exit | done | | +| "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not an Illustrator feature as documented; under discussion | + +## Smart guides and snapping + +| Behaviour | Status | Notes | +|---|---|---| +| Snap to anchors, centres, edges, artboards, bleed and ruler guides with magenta lines | done | `guides.rs` kinds: Anchor, Center, Edge, Artboard, Bleed, Guide | +| Anchor/Path labels ("anchor", "center", "path", "align") | done | | +| "intersect" label and snapping to path intersections | missing | | +| Construction guides at preference angles while drawing | done | #506 | +| Measurement labels while drawing, moving and resizing | partial | sizes and offsets; angle and distance readouts while drawing missing | +| Spacing guides (equal distances) | partial | for moves (#394); not for resizing or drawing | +| Snapping tolerance preference | done | | +| Snap to Point, Snap to Grid, Snap to Pixel; Pixel Preview | done | | +| Snap to Glyph | missing | menu stub | +| Tangent and perpendicular snapping for line endpoints (new in 30.0) | missing | | +| Snapping panel/popover consolidating the snap options (30.0) | missing | | +| Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only | +| Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451 | +| Ruler guides as Layers rows; Alt swaps a ruler guide's orientation while dragging | missing | | +| Global and video rulers | missing | menu stubs | + +## Transform handles, bounding box, Free Transform + +| Behaviour | Status | Notes | +|---|---|---| +| Bounding-box handles: Shift proportional, Alt from centre, rotate outside the corners | done | | +| Rotated objects keep a rotated bounding box and their angle; Reset Bounding Box | done | | +| Free Transform, Scale and Reflect along a rotated box | missing | | +| Free Transform widget: constrain, free distort, perspective distort; the key-held variants | done | | +| Transform tools: click sets the reference point (snaps to anchors/centres), Alt-click opens the dialog, Alt-release copies | done | | +| Transform Again, Transform Each, Scale Strokes & Effects, Scale Corners | done | Transform Again after a type-area resize scales the type (gap) | +| Reference point locator (nine squares) in Control bar and Transform panel | done | | + +## Nudging, numeric entry and precision + +| Behaviour | Status | Notes | +|---|---|---| +| Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | | +| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a focused field | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991) | +| Arithmetic in fields (`+ - * /`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | +| Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) | +| Field precision: 3 decimals for steps; values kept as f64 | done | | +| Scrubby labels (beyond Illustrator) | done | #400 | +| Dialogs open with the first field focused and selected; Enter confirms | done | | + +## Modifier keys and temporary tools + +| Behaviour | Status | Notes | +|---|---|---| +| Space: Hand; Cmd+Space / Cmd+Alt+Space: zoom in/out; middle button pans | done | | +| Cmd/Ctrl: last-used selection tool; Cmd+Alt: Group Selection inside Direct Selection | partial | | +| Alt-drag duplicates (objects, artboards with their art) | done | | +| Shape tools: Shift constrains, Alt from centre, Space moves while dragging, arrows change sides/points/rows | done | | +| Tool double-click opens its options | done | | +| Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | | +| Every tool's modifiers checked against the shortcut reference | missing | this is the fidelity pass | + +## Keyboard shortcuts + +~110 distinct modified shortcuts on menu commands plus single-key tool shortcuts for the default toolbar +(`crates/tools/src/catalog.rs`), a shortcut editor with chords, and saved sets. Not yet diffed item by item +against Illustrator's published default shortcut list; estimated ~70% of it. Remaining: the panel shortcuts, +the type shortcuts (tracking/kerning/leading/baseline steps with Alt/Shift), and selection shortcuts in the +Layers panel. + +## Panels, Control bar, Properties, context menus + +| Behaviour | Status | Notes | +|---|---|---| +| ~40 panels with ≡ menus; dock, icon column, collapse, tabs | done | 51 panel modules incl. tests | +| Panels float inside the window, stack, dock back | done | #495 | +| Panels outside the app window (another monitor) | missing | single OS window | +| Properties panel per context (every selection type's Quick Actions) | partial | | +| Contextual task bar | done | | +| Control bar with Transform and Stroke popovers | done | | +| Canvas context menu | done | Layers rows, panels and type have none (gap) | +| Blend panel (30.7) | missing | Blend Options dialog exists | +| SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | +| Workspaces (save, reset, switch) | done | | +| Preferences: every option acts | partial | ~17 stored but unused (#394) | + +## Look and feel + +Medium Dark theme and four brightness levels, the categorized and Advanced toolbars, 35 pt document tabs, +33 pt panel tabs, hint bar, contextual task bar and native macOS menus, restyled to values measured from +public screenshots on 2026-10-02 (~75–80% then). Missing: a System theme that follows the OS (#825), a +re-measure of the panels added since. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation | From 59c890df9e3fbe270b0b1a95b8392076fd4f7b4f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 00:48:35 +0700 Subject: [PATCH 05/97] =?UTF-8?q?Part=20of=20gap=202:=20Effect=20=E2=80=BA?= =?UTF-8?q?=20Brush=20Strokes=20(Accented=20Edges,=20Angled=20Strokes,=20C?= =?UTF-8?q?rosshatch,=20Dark=20Strokes,=20Ink=20Outlines,=20Spatter,=20Spr?= =?UTF-8?q?ayed=20Strokes,=20Sumi-e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight raster effects on the pixel-filter pipeline (vectorcraft_effects::pixel::brushstrokes), under a new Effect › Brush Strokes submenu between Blur and Distort in the Raster Effects section: - Accented Edges (edgeWidth pt 1..14, default 2; edgeBrightness 0..50, 38; smoothness 1..15, 5): the edges of the softened object painted towards white chalk at high brightness or black ink at low brightness; Smoothness also leaves the faint edges out. The shape stays as it was. - Angled Strokes (directionBalance 0..100, 50; strokeLength pt 3..50, 15; sharpness 0..10, 3): light areas in right diagonal strokes, dark areas in left diagonal ones; 0 makes every stroke a left diagonal, 100 every stroke a right diagonal. Sharpness brings detail and bristles back. - Crosshatch (strokeLength pt 3..50, 9; sharpness 0..20, 6; strength 1..3, 1): the detail kept under pencil hatching along both diagonals, Strength passes of it, the edges roughened. - Dark Strokes (balance 0..10, 5; blackIntensity 0..10, 6; whiteIntensity 0..10, 2): short strokes towards black in the dark areas, long strokes towards white in the light areas. - Ink Outlines (strokeLength pt 1..50, 4; darkIntensity 0..50, 20; lightIntensity 0..50, 10): black outlines over the edges, ink hatching in the shadows, shadows darkened and highlights lightened. - Spatter (sprayRadius pt 0..25, 10; smoothness 1..15, 5): each pixel shifted by noise up to 0.4 x the radius; Smoothness makes the grains larger and smoother. - Sprayed Strokes (strokeLength pt 0..20, 12; sprayRadius pt 0..25, 7; strokeDirection Right Diagonal, Horizontal, Left Diagonal or Vertical): strokes along the direction, scattered by up to 0.3 x the radius. - Sumi-e (strokeWidth pt 3..15, 10; strokePressure 0..15, 2; contrast 0..40, 16): wet strokes, ink bleeding from the darks along paper fibres, more ink with pressure and an S-curve contrast for rich blacks. It works in value only: hues and saturation stay, and so does the object's shape (no fringe in the transparency). A stroke averages the colour along a segment through the pixel (16 samples at most, the art softened first when they lie more than a pixel apart); strokes lie in rows around the object's centre, each row starting at random and each stroke shifted along itself, with streaked noise for the bristles. Everything is in document space, so the effects look the same at any zoom or resolution, move with the object and draw the same every time; outsets and reaches are exact. On a 2000 x 2000 raster in release the slowest settings take 0.3-2.0 s (Crosshatch at 50 pt with 3 passes the most). Shared helpers instead of copies: edges::sobel (Glowing Edges now uses it, unchanged), distort::displace and texture::Tones are pub(super). Catalogue rows with parameter docs, menu entries, Sprayed Strokes' direction dropdown, translations (de, es, fr, it, ja, ru, uk, zh-hans, zh-hant; menu labels in cs and pt-br), tests (parameters and clamping, outsets and reaches, each filter and option showing, chalk and ink on the edges only, shadows and rich blacks, Sumi-e keeping the shape and hues, hatching keeping the tone, strokes smearing along their direction, Spatter's radius, resolution and move invariance, canvas, multithreaded, PDF and SVG, the command sweep with junk parameters, the menu) and ROADMAP. Behaviour from public documentation only: https://helpx.adobe.com/photoshop-elements/using/brush-stroke-filters.html https://www.computerhope.com/jargon/p/photoshop-sprayed-strokes.htm Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- ROADMAP.md | 11 +- crates/effects/src/lib.rs | 62 +++ crates/effects/src/pixel/brushstrokes.rs | 518 +++++++++++++++++++++++ crates/effects/src/pixel/distort.rs | 2 +- crates/effects/src/pixel/edges.rs | 66 +-- crates/effects/src/pixel/mod.rs | 115 ++++- crates/effects/src/pixel/tests.rs | 327 ++++++++++++++ crates/effects/src/pixel/texture.rs | 6 +- crates/engine/src/tests_rasterfilters.rs | 21 +- crates/engine/tests/command_sweep.rs | 20 + crates/ui-egui/src/dialogs/effect.rs | 1 + crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/de.tsv | 31 ++ crates/ui-egui/src/i18n/es.tsv | 30 ++ crates/ui-egui/src/i18n/fr.tsv | 30 ++ crates/ui-egui/src/i18n/it.tsv | 30 ++ crates/ui-egui/src/i18n/ja.tsv | 30 ++ crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 30 ++ crates/ui-egui/src/i18n/uk.tsv | 30 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 30 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 30 ++ crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/tests_labels.rs | 18 + docs/effects-parity.md | 7 +- docs/gaps.md | 10 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 6 +- 29 files changed, 1420 insertions(+), 64 deletions(-) create mode 100644 crates/effects/src/pixel/brushstrokes.rs diff --git a/README.md b/README.md index f86423fb9..0d0457fdd 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ The scores are self-assessed from our code against Illustrator's public document - reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); -- most of the Photoshop-style raster effects (20 of 57) and the Effect Gallery; +- about half of the Photoshop-style raster effects (29 of 57 still missing) and the Effect Gallery; - CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface); - scripting, and the rest of Variables (image and graph kinds, dataset import); - generative AI features. diff --git a/ROADMAP.md b/ROADMAP.md index c422d4c08..b97cf0a22 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (full readiness ~62% by the standard's additive method; per-audience hours; essentials-user score ~75% added; ready for real work re-examined: 55% full, 65% mainstream; core-workflow gate applied: stays alpha; earlier the same day: full re-measure against Illustrator 2026 30.x, restructured to the craftrules progress-docs standard, detail moved to `docs/`) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -22,7 +22,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). | | Value | Kind | |---|---|---| -| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 20/57, 3D 1/4 (initial Revolve) | +| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 28/57, 3D 1/4 (initial Revolve) | | **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | | **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | | **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated | @@ -31,7 +31,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). | Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates | |---|---:|---:|---:|---| -| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (30–50 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem | +| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (24–40 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem | | **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas | | **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish | @@ -69,7 +69,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so | Strokes, brushes, width profiles | 88% | 8–15 | | Appearance, transparency, graphic styles, masks | 97% | 1–2 | | Live vector effects | 85% | 6–10 | -| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 40% | 30–50 | +| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 48% | 24–40 | | 3D and Materials | ~8% | 50–85 | | Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 | | Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 | @@ -116,13 +116,14 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap 2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h. 3. **Launch and stability on real machines** (G10): 20–35 h. 4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h. -5. **Raster effects** (G2) alongside: 37 filters and the Effect Gallery, 30–50 h. +5. **Raster effects** (G2) alongside: 29 filters and the Effect Gallery, 24–40 h. 6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3). ## Progress log | Date | What landed | |---|---| +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2) | | 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/effects/src/lib.rs b/crates/effects/src/lib.rs index 1814ef08c..1c12a5c88 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -131,6 +131,11 @@ fn lengths_of(id: &str) -> Lengths { "stylize.dropShadow" => always(&["x", "y", "blur"]), "stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]), "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), + "brushStrokes.accentedEdges" => always(&["edgeWidth"]), + "brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]), + "brushStrokes.spatter" => always(&["sprayRadius"]), + "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), + "brushStrokes.sumiE" => always(&["strokeWidth"]), "pixelate.colorHalftone" => always(&["maxRadius"]), "pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]), "texture.craquelure" => always(&["crackSpacing"]), @@ -149,6 +154,7 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"]; const STYLIZE: &[&str] = &["Effect", "Stylize"]; const WARP: &[&str] = &["Effect", "Warp"]; const BLUR: &[&str] = &["Effect", "Blur"]; +const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"]; const DISTORT: &[&str] = &["Effect", "Distort"]; const SHARPEN: &[&str] = &["Effect", "Sharpen"]; const PIXELATE: &[&str] = &["Effect", "Pixelate"]; @@ -306,6 +312,62 @@ pub fn effect_catalog() -> Vec { "{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)", json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}), ), + r( + "brushStrokes.accentedEdges", + "Accented Edges…", + BRUSH_STROKES, + "{edgeWidth: pt 1..14 (2), edgeBrightness: 0..50 (38; high: white chalk, low: black ink), smoothness: 1..15 (5; softens the object first and leaves its faint edges out)} accentuates the object's edges", + json!({"edgeWidth": 2.0, "edgeBrightness": 38.0, "smoothness": 5.0}), + ), + r( + "brushStrokes.angledStrokes", + "Angled Strokes…", + BRUSH_STROKES, + "{directionBalance: 0..100 (50; 0: every stroke a left diagonal, 100: every stroke a right diagonal, between: light areas right, dark areas left), strokeLength: pt 3..50 (15), sharpness: 0..10 (3)} repaints the object with diagonal strokes, the light areas' going the opposite way to the dark areas'", + json!({"directionBalance": 50.0, "strokeLength": 15.0, "sharpness": 3.0}), + ), + r( + "brushStrokes.crosshatch", + "Crosshatch…", + BRUSH_STROKES, + "{strokeLength: pt 3..50 (9), sharpness: 0..20 (6), strength: 1..3 (1; passes of hatching)} keeps the object's detail under pencil hatching along both diagonals and roughens its edges", + json!({"strokeLength": 9.0, "sharpness": 6.0, "strength": 1.0}), + ), + r( + "brushStrokes.darkStrokes", + "Dark Strokes…", + BRUSH_STROKES, + "{balance: 0..10 (5; the higher, the more of the object counts as dark), blackIntensity: 0..10 (6), whiteIntensity: 0..10 (2)} paints the dark areas closer to black with short strokes and the light areas with long white strokes", + json!({"balance": 5.0, "blackIntensity": 6.0, "whiteIntensity": 2.0}), + ), + r( + "brushStrokes.inkOutlines", + "Ink Outlines…", + BRUSH_STROKES, + "{strokeLength: pt 1..50 (4), darkIntensity: 0..50 (20), lightIntensity: 0..50 (10)} redraws the object in pen and ink: fine outlines over its edges and ink strokes in its shadows", + json!({"strokeLength": 4.0, "darkIntensity": 20.0, "lightIntensity": 10.0}), + ), + r( + "brushStrokes.spatter", + "Spatter…", + BRUSH_STROKES, + "{sprayRadius: pt 0..25 (10; colours scattered up to 0.4 × this), smoothness: 1..15 (5; larger, smoother grains)} renders the object as if sprayed with a spatter airbrush", + json!({"sprayRadius": 10.0, "smoothness": 5.0}), + ), + r( + "brushStrokes.sprayedStrokes", + "Sprayed Strokes…", + BRUSH_STROKES, + "{strokeLength: pt 0..20 (12), sprayRadius: pt 0..25 (7; strokes scattered up to 0.3 × this), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} repaints the object with sprayed, angled strokes of its main colours", + json!({"strokeLength": 12.0, "sprayRadius": 7.0, "strokeDirection": "rightDiagonal"}), + ), + r( + "brushStrokes.sumiE", + "Sumi-e…", + BRUSH_STROKES, + "{strokeWidth: pt 3..15 (10), strokePressure: 0..15 (2; more ink), contrast: 0..40 (16)} paints the object in the Japanese style, as if with a wet brush full of black ink on rice paper: soft, blurred edges and rich blacks, the colours keeping their hue and the object its shape", + json!({"strokeWidth": 10.0, "strokePressure": 2.0, "contrast": 16.0}), + ), r( "distort.diffuseGlow", "Diffuse Glow…", diff --git a/crates/effects/src/pixel/brushstrokes.rs b/crates/effects/src/pixel/brushstrokes.rs new file mode 100644 index 000000000..416a67de4 --- /dev/null +++ b/crates/effects/src/pixel/brushstrokes.rs @@ -0,0 +1,518 @@ +//! Effect › Brush Strokes (Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, +//! Spatter, Sprayed Strokes, Sumi-e). +//! +//! The stroke filters repaint the object with brush strokes: each pixel takes the object's colour +//! averaged along a segment through it ([`Smear`], a bounded number of samples whatever the +//! length). Strokes lie in rows across their direction, each row starting at random and each +//! stroke shifted along itself at random, so they begin and end like separate strokes; a streaked +//! noise along them gives them their bristle texture. Accented Edges and Ink Outlines find edges +//! with Glowing Edges' Sobel operator over the softened object; Spatter and Sprayed Strokes shift +//! the content by noise as Ocean Ripple does. Every pattern lies in document space around the +//! object's centre, as the Texture filters' do. + +use std::f64::consts::{FRAC_1_SQRT_2, TAU}; + +use vectorcraft_geom::{Point, Vec2}; + +use super::distort::displace; +use super::edges::sobel; +use super::pixelate::{Place, cell, rand, value_noise}; +use super::texture::{Tones, luma, premultiply, smooth_noise, straight}; +use super::{PixelSpace, blur_plane, gaussian, pick, to16}; + +const SALT_ANGLED: u64 = 144; +const SALT_CROSS: u64 = 152; +const SALT_DARK: u64 = 160; +const SALT_INK: u64 = 168; +const SALT_SPATTER: u64 = 176; +const SALT_SPRAYED: u64 = 184; +const SALT_SUMI: u64 = 192; + +/// Sprayed Strokes' stroke directions, in its Stroke Direction menu's order: (label, parameter +/// value). +pub const STROKE_DIRECTIONS: [(&str, &str); 4] = + [("Right Diagonal", "rightDiagonal"), ("Horizontal", "horizontal"), ("Left Diagonal", "leftDiagonal"), ("Vertical", "vertical")]; + +/// The way Sprayed Strokes are painted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StrokeDirection { + RightDiagonal, + Horizontal, + LeftDiagonal, + Vertical, +} + +impl StrokeDirection { + const ALL: [StrokeDirection; 4] = + [StrokeDirection::RightDiagonal, StrokeDirection::Horizontal, StrokeDirection::LeftDiagonal, StrokeDirection::Vertical]; + + /// The direction of `value` (a [`STROKE_DIRECTIONS`] value, any case); Right Diagonal + /// otherwise. + pub fn parse(value: &str) -> Self { + pick(&STROKE_DIRECTIONS, &Self::ALL, value, StrokeDirection::RightDiagonal) + } + + /// The unit vector along the strokes (document axes, y down). + fn along(self) -> Vec2 { + match self { + StrokeDirection::RightDiagonal => RIGHT, + StrokeDirection::Horizontal => Vec2::new(1.0, 0.0), + StrokeDirection::LeftDiagonal => LEFT, + StrokeDirection::Vertical => Vec2::new(0.0, 1.0), + } + } +} + +/// Along a right diagonal stroke, rising to the right (y down). +const RIGHT: Vec2 = Vec2::new(FRAC_1_SQRT_2, -FRAC_1_SQRT_2); +/// Along a left diagonal stroke, falling to the right. +const LEFT: Vec2 = Vec2::new(FRAC_1_SQRT_2, FRAC_1_SQRT_2); + +/// The most samples a stroke averages, whatever its length. +const MAX_SAMPLES: usize = 16; + +/// The share of its length by which a stroke is shifted along itself at random, at most. +const JITTER: f64 = 0.25; + +/// How soft (points, the σ) the tones are that decide which strokes a pixel gets. +const TONE_SOFTNESS: f64 = 1.0; + +/// Dark Strokes' short dark strokes and long light ones (points). +pub(super) const DARK_LENGTH: f64 = 3.0; +pub(super) const LIGHT_LENGTH: f64 = 9.0; + +/// How soft (points, the σ) Ink Outlines' outlines are. +const INK_SOFTNESS: f64 = 0.6; + +/// A pixel at 72 ppi (points): how far the neighbours of the Sobel operator and of sampling +/// between pixels add to a filter's reach. +const MARGIN: f64 = 1.0; + +/// How far (document units) strokes `len` long reach: half their length, their random shift, the +/// softening between their samples and the pixels a sample is taken between. +pub(super) fn stroke_reach(len: f64) -> f64 { + let len = if len.is_finite() { len.max(0.0) } else { 0.0 }; + (0.5 + JITTER + 1.5 / (MAX_SAMPLES - 1) as f64) * len + MARGIN +} + +/// How far (document units) the tones reach that the stroke filters choose their strokes by. +pub(super) const TONE_REACH: f64 = 3.0 * TONE_SOFTNESS + MARGIN; + +/// How far Sumi-e reaches with strokes `width` across: the strokes, and the wet ink bleeding from +/// 3 σ of 0.2 × the width. +pub(super) fn sumi_reach(width: f64) -> f64 { + stroke_reach(width).max(0.6 * width + MARGIN) +} + +/// The σ (points) Accented Edges softens the object by before finding its edges, for Edge Width +/// `width` (1..14) and Smoothness `smoothness` (1..15). +pub(super) fn accent_softness(width: f64, smoothness: f64) -> f64 { + 0.35 * width.clamp(1.0, 14.0) + 0.1 * smoothness.clamp(1.0, 15.0) +} + +/// How far Accented Edges reaches for Edge Width `width` and Smoothness `smoothness`. +pub(super) fn accent_reach(width: f64, smoothness: f64) -> f64 { + 3.0 * accent_softness(width, smoothness) + MARGIN +} + +/// How far Ink Outlines reaches with strokes `len` long. +pub(super) fn ink_reach(len: f64) -> f64 { + stroke_reach(len).max(3.0 * INK_SOFTNESS + MARGIN) +} + +/// How far (points) Crosshatch roughens the edges, with strokes `len` long. +pub(super) fn rough(len: f64) -> f64 { + 0.1 * if len.is_finite() { len.max(0.0) } else { 0.0 } +} + +/// The longest shift (points) Spatter gives at Spray Radius `radius` (0..25). +pub(super) fn spatter_shift(radius: f64) -> f64 { + 0.4 * radius.clamp(0.0, 25.0) +} + +/// The longest shift (points) Sprayed Strokes' spray gives at Spray Radius `radius` (0..25). +pub(super) fn spray_shift(radius: f64) -> f64 { + 0.3 * radius.clamp(0.0, 25.0) +} + +/// The object's colours averaged along strokes `len` long: [`MAX_SAMPLES`] at most, the object +/// softened first when they lie more than a pixel apart. +struct Smear<'a> { + /// Premultiplied colours (0..1). + src: Vec<[f32; 4]>, + w: usize, + h: usize, + place: &'a Place, + len: f64, + n: usize, +} + +impl<'a> Smear<'a> { + fn new(px: &[[u8; 4]], w: usize, h: usize, place: &'a Place, len: f64) -> Self { + let len = if len.is_finite() { len.max(0.0) } else { 0.0 }; + let span = len / place.px; + let n = if span.is_finite() { (span.ceil() as usize).saturating_add(1).clamp(1, MAX_SAMPLES) } else { MAX_SAMPLES }; + let gap = if n > 1 { span / (n - 1) as f64 } else { 0.0 }; + let mut wide = to16(px); + if gap > 1.0 { + gaussian(&mut wide, w, 0.5 * gap); + } + let src = wide.iter().map(|p| p.map(|v| f32::from(v) / 65535.0)).collect(); + Self { src, w, h, place, len, n } + } + + /// The premultiplied colour (0..1) averaged along the stroke through `centre` (relative to the + /// object's centre) along `dir`. + fn at(&self, centre: Point, dir: Vec2) -> [f32; 4] { + let half = dir * (0.5 * self.len); + let (a, b) = (self.place.to_px * (centre - half), self.place.to_px * (centre + half)); + if !(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) { + return [0.0; 4]; + } + // Pixel centres at whole coordinates. + let (mut x, mut y, step) = match self.n { + 0 | 1 => (0.5 * (a.x + b.x) - 0.5, 0.5 * (a.y + b.y) - 0.5, (0.0, 0.0)), + n => (a.x - 0.5, a.y - 0.5, ((b.x - a.x) / (n - 1) as f64, (b.y - a.y) / (n - 1) as f64)), + }; + let mut acc = [0.0f32; 4]; + for _ in 0..self.n { + self.add(&mut acc, x, y); + x += step.0; + y += step.1; + } + acc.map(|v| v / self.n.max(1) as f32) + } + + /// Add the colour at (`x`, `y`) (pixel centres at whole coordinates), interpolated between the + /// four pixels around it, to `acc`; transparent outside. + fn add(&self, acc: &mut [f32; 4], x: f64, y: f64) { + let (x0, y0) = (x.floor(), y.floor()); + if !(x0 >= -1.0 && y0 >= -1.0 && x0 < self.w as f64 && y0 < self.h as f64) { + return; + } + let (fx, fy) = ((x - x0) as f32, (y - y0) as f32); + let (i, j) = (x0 as isize, y0 as isize); + // Inside the raster: the four pixels as two pairs. + if i >= 0 && j >= 0 && (i as usize) + 1 < self.w && (j as usize) + 1 < self.h { + let at = j as usize * self.w + i as usize; + if let (Some([p00, p10]), Some([p01, p11])) = (self.src.get(at..at + 2), self.src.get(at + self.w..at + self.w + 2)) { + let (k00, k10, k01, k11) = ((1.0 - fx) * (1.0 - fy), fx * (1.0 - fy), (1.0 - fx) * fy, fx * fy); + for (c, a) in acc.iter_mut().enumerate() { + *a += p00[c] * k00 + p10[c] * k10 + p01[c] * k01 + p11[c] * k11; + } + } + return; + } + for (row, wy) in [(j, 1.0 - fy), (j + 1, fy)] { + let Some(row) = usize::try_from(row).ok().filter(|r| *r < self.h) else { continue }; + for (col, wx) in [(i, 1.0 - fx), (i + 1, fx)] { + let Some(col) = usize::try_from(col).ok().filter(|c| *c < self.w) else { continue }; + if let Some(p) = self.src.get(row * self.w + col) { + let k = wx * wy; + for (a, v) in acc.iter_mut().zip(p) { + *a += v * k; + } + } + } + } + } + + /// The stroke of pattern `salt` through `q` along `dir`, its rows `width` apart. + fn stroke(&self, salt: u64, q: Point, dir: Vec2, width: f64) -> [f32; 4] { + self.at(q + dir * stroke_shift(salt, q, dir, self.len, width), dir) + } +} + +/// `q`'s coordinates along `dir` (unit) and across it. +fn frame(q: Point, dir: Vec2) -> (f64, f64) { + (q.x * dir.x + q.y * dir.y, q.y * dir.x - q.x * dir.y) +} + +/// How far along `dir` the stroke through `q` is shifted: strokes `len` long lie in rows `width` +/// apart across `dir`, each row starting at random, each stroke shifted by up to [`JITTER`] × +/// `len`. +fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 { + if !(len > 0.0 && width > 0.0) { + return 0.0; + } + let (u, v) = frame(q, dir); + let Some(row) = cell(v / width) else { return 0.0 }; + let Some(col) = cell(u / len + rand(salt, row, 0, 0)) else { return 0.0 }; + (2.0 * rand(salt, col, row, 1) - 1.0) * JITTER * len +} + +/// Smooth noise in [-1, 1) streaked along `dir`: blobs `along` long and `across` wide. +fn streak(salt: u64, q: Point, dir: Vec2, along: f64, across: f64) -> f64 { + let (u, v) = frame(q, dir); + 2.0 * value_noise(salt, u / along.max(1e-3), v / across.max(1e-3)) - 1.0 +} + +/// The straight colour and coverage of premultiplied `pm` (0..1). +fn unpremultiply(pm: [f32; 4]) -> ([f32; 3], f32) { + let a = pm[3].clamp(0.0, 1.0); + if a > 1e-6 { ([pm[0], pm[1], pm[2]].map(|v| (v / a).clamp(0.0, 1.0)), a) } else { ([0.0; 3], 0.0) } +} + +/// `a` + (`b` − `a`) × `t`, channel by channel. +fn mix(a: [f32; N], b: [f32; N], t: f32) -> [f32; N] { + std::array::from_fn(|k| a[k] + (b[k] - a[k]) * t) +} + +/// A smooth step from 0 at `t` ≤ 0 to 1 at `t` ≥ 1. +fn ramp(t: f64) -> f64 { + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + t * t * (3.0 - 2.0 * t) +} + +/// Pixel `p` (premultiplied 0..255) as premultiplied 0..1. +fn unit(p: [u8; 4]) -> [f32; 4] { + p.map(|v| f32::from(v) / 255.0) +} + +/// The object's edges after softening it by `sigma` (document units): about 1 across a step from +/// black to white or from clear to opaque, the same at any resolution. Empty when the sizes don't +/// match. +fn edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec { + let mut tone: Vec = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect(); + let mut cover: Vec = px.iter().map(|p| f32::from(p[3]) / 255.0).collect(); + let s = sigma / place.px; + blur_plane(&mut tone, w, h, s); + blur_plane(&mut cover, w, h, s); + // A step softened by σ pixels rises by 1 / (σ √(2π)) per pixel at most, and Sobel gives 8 × + // the slope; a pixel is the least it softens by. + let norm = (s.max(0.8) * TAU.sqrt() / 8.0) as f32; + let norm = if norm.is_finite() { norm } else { 0.0 }; + sobel(&tone, w, h).iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c) * norm).collect() +} + +/// Accented Edges: the object's edges accented in white chalk when `brightness` (0..50) is high, +/// in black ink when it's low, about `width` (1..14) wide; `smoothness` (1..15) softens the object +/// first and leaves out its fainter edges. +pub(super) fn accented_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, brightness: f64, smoothness: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + let found = edges(px, w, h, &place, accent_softness(width, smoothness)); + let faint = (0.02 * smoothness.clamp(1.0, 15.0)) as f32; + let level = (brightness / 50.0).clamp(0.0, 1.0) as f32; + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let e = found.get(y * w + x).copied().unwrap_or(0.0); + let e = (2.5 * (e - faint) / (1.0 - faint)).clamp(0.0, 1.0); + let c = straight(*p); + // Chalk or ink of the brightness level, a little of the colour showing through. + let accent = c.map(|v| 0.9 * level + 0.1 * v); + *p = premultiply(mix(c, accent, e), f32::from(p[3]) / 255.0); + }); +} + +/// Angled Strokes: the object repainted in diagonal strokes `len` long, light areas in right +/// diagonal strokes and dark areas in left diagonal ones; `balance` (0..100) moves the split +/// (0 all left diagonal, 100 all right diagonal) and `sharpness` (0..10) brings the detail and +/// the bristles out. +pub(super) fn angled_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, len: f64, sharpness: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + let smear = Smear::new(px, w, h, &place, len); + let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px); + let split = 1.07 - 1.14 * (balance / 100.0).clamp(0.0, 1.0); + let sharp = sharpness.clamp(0.0, 10.0); + let (detail, bristle) = ((0.05 * sharp) as f32, 0.03 + 0.012 * sharp); + let row = (0.2 * smear.len).max(1.0); + let src = px.to_vec(); + place.each(px, |x, y, p| { + let q = place.at(x, y); + // The split between the directions wanders along the strokes, so they interlock. + let right = tones.get(x, y) > split + 0.06 * streak(SALT_ANGLED + 3, q, RIGHT, row, row); + let (dir, salt) = if right { (RIGHT, SALT_ANGLED) } else { (LEFT, SALT_ANGLED + 1) }; + let pm = smear.stroke(salt, q, dir, row); + let pm = mix(pm, unit(src.get(y * w + x).copied().unwrap_or([0; 4])), detail); + let (c, a) = unpremultiply(pm); + let n = (bristle * streak(SALT_ANGLED + 2, q, dir, 0.5 * smear.len.max(1.0), 0.6)) as f32; + *p = premultiply(c.map(|v| v + n), a); + }); +} + +/// Crosshatch: the object's detail kept under pencil hatching along both diagonals, strokes `len` +/// long, roughening its edges; `sharpness` (0..20) sets how crisp and dark the hatching is, +/// `passes` (1..3) how many layers of it there are. +pub(super) fn crosshatch(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, sharpness: f64, passes: u32) { + let Some(place) = Place::new(space, w, h) else { return }; + let smear = Smear::new(px, w, h, &place, len); + let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px); + let sharp = sharpness.clamp(0.0, 20.0); + let (amount, crisp) = (0.05 + 0.01 * sharp, 1.0 + 0.3 * sharp); + let row = (0.2 * smear.len).max(1.0); + let roughen = rough(smear.len); + let src = px.to_vec(); + place.each(px, |x, y, p| { + let q = place.at(x, y); + let line = |k: u32| { + let dir = if k.is_multiple_of(2) { RIGHT } else { LEFT }; + streak(SALT_CROSS + u64::from(k), q, dir, smear.len.max(1.0), 0.7 + 0.3 * f64::from(k)) + }; + let first = line(0); + // The edges roughened along the first hatching. + let o = q + RIGHT * (roughen * first); + let strokes = mix(smear.stroke(SALT_CROSS + 4, o, RIGHT, row), smear.stroke(SALT_CROSS + 5, o, LEFT, row), 0.5); + let pm = mix(unit(src.get(y * w + x).copied().unwrap_or([0; 4])), strokes, 0.5); + let (mut c, a) = unpremultiply(pm); + if a <= 0.0 { + *p = [0; 4]; + return; + } + let shade = 1.3 - tones.get(x, y); + for k in 0..passes.clamp(1, 3) { + let v = (if k == 0 { first } else { line(k) } * crisp).clamp(-1.0, 1.0); + // Dark pencil lines and the light paper between them. + let s = if v > 0.0 { -(amount * v * shade) } else { -0.6 * amount * v }; + let s = s.clamp(-1.0, 1.0) as f32; + c = c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s }); + } + *p = premultiply(c, a); + }); +} + +/// Dark Strokes: dark areas painted closer to black with short strokes, light areas with long +/// strokes closer to white; `balance` (0..10) moves the tone between them up, `black` (0..10) and +/// `white` (0..10) set how dark and how light they get. +pub(super) fn dark_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, black: f64, white: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + let short = Smear::new(px, w, h, &place, DARK_LENGTH); + let long = Smear::new(px, w, h, &place, LIGHT_LENGTH); + let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px); + let split = 0.15 + 0.07 * balance.clamp(0.0, 10.0); + let (black, white) = (0.085 * black.clamp(0.0, 10.0), 0.085 * white.clamp(0.0, 10.0)); + place.each(px, |x, y, p| { + let q = place.at(x, y); + let dark = 1.0 - ramp((tones.get(x, y) - split) / 0.12 + 0.5); + let painted = |light: bool| { + let (smear, dir, salt) = if light { (&long, RIGHT, SALT_DARK) } else { (&short, LEFT, SALT_DARK + 1) }; + let (c, a) = unpremultiply(smear.stroke(salt, q, dir, 1.0)); + let n = 0.7 + 0.3 * streak(salt + 2, q, dir, 0.5 * smear.len, 0.5); + let c = if light { + let s = (white * n).clamp(0.0, 1.0) as f32; + c.map(|v| v + (1.0 - v) * s) + } else { + let s = (black * n).clamp(0.0, 1.0) as f32; + c.map(|v| v * (1.0 - s)) + }; + [c[0] * a, c[1] * a, c[2] * a, a] + }; + let pm = if dark >= 1.0 { + painted(false) + } else if dark <= 0.0 { + painted(true) + } else { + mix(painted(true), painted(false), dark as f32) + }; + let (c, a) = unpremultiply(pm); + *p = premultiply(c, a); + }); +} + +/// Ink Outlines: the object redrawn in pen and ink, fine outlines over its edges and ink strokes +/// `len` long in its shadows; `dark` (0..50) darkens the shadows, `light` (0..50) lightens the +/// highlights. +pub(super) fn ink_outlines(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, dark: f64, light: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + let smear = Smear::new(px, w, h, &place, len); + let found = edges(px, w, h, &place, INK_SOFTNESS); + let (dark, light) = ((dark / 50.0).clamp(0.0, 1.0), (light / 50.0).clamp(0.0, 1.0)); + let row = (0.25 * smear.len).max(0.75); + let src = px.to_vec(); + place.each(px, |x, y, p| { + let q = place.at(x, y); + let pm = mix(unit(src.get(y * w + x).copied().unwrap_or([0; 4])), smear.stroke(SALT_INK, q, LEFT, row), 0.5); + let (c, a) = unpremultiply(pm); + if a <= 0.0 { + *p = [0; 4]; + return; + } + let l = f64::from(luma(c)); + let (shadow, highlight) = (((0.75 - l) / 0.75).clamp(0.0, 1.0), ((l - 0.45) / 0.55).clamp(0.0, 1.0)); + let (down, up) = ((0.7 * dark * shadow) as f32, (0.7 * light * highlight) as f32); + let c = c.map(|v| v * (1.0 - down)).map(|v| v + (1.0 - v) * up); + // Ink lines along the strokes in the shadows, and the outlines. + let hatch = ramp(2.0 * streak(SALT_INK + 1, q, LEFT, 1.5 * smear.len.max(1.0), 0.5) + 0.2) * shadow * (0.4 + 1.2 * dark); + let outline = (1.5 * f64::from(found.get(y * w + x).copied().unwrap_or(0.0)) - 0.15).clamp(0.0, 1.0); + let ink = (0.9 * outline).max(0.8 * hatch).min(1.0) as f32; + *p = premultiply(c.map(|v| v * (1.0 - ink)), a); + }); +} + +/// Spatter: the object as if sprayed with a spatter airbrush, each pixel taking the colour of one +/// up to [`spatter_shift`] away; `smoothness` (1..15) makes the spatter's grains larger and +/// smoother. +pub(super) fn spatter(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, radius: f64, smoothness: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + let grain = 0.3 + 0.15 * smoothness.clamp(1.0, 15.0); + let most = spatter_shift(radius); + displace(px, w, h, &place, most, |q| { + let n = |salt: u64| 0.6 * smooth_noise(salt, q, grain) + 0.4 * smooth_noise(salt + 2, q, 0.4 * grain); + Vec2::new(n(SALT_SPATTER), n(SALT_SPATTER + 1)) * (1.8 * most) + }); +} + +/// Sprayed Strokes: the object repainted in sprayed strokes `len` long along `dir`, scattered by +/// up to [`spray_shift`]. +pub(super) fn sprayed_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, radius: f64, dir: StrokeDirection) { + let Some(place) = Place::new(space, w, h) else { return }; + let smear = Smear::new(px, w, h, &place, len); + let (dir, spray) = (dir.along(), spray_shift(radius)); + let along = (0.5 * smear.len).max(0.5); + place.each(px, |x, y, p| { + let q = place.at(x, y); + let n = |salt: u64| streak(salt, q, dir, along, 0.5); + let mut d = Vec2::new(n(SALT_SPRAYED), n(SALT_SPRAYED + 1)) * (1.6 * spray); + let l = d.hypot(); + if l > spray && l.is_finite() { + d *= spray / l; + } + let (c, a) = unpremultiply(smear.stroke(SALT_SPRAYED + 2, q + d, dir, (0.2 * smear.len).max(1.0))); + *p = premultiply(c, a); + }); +} + +/// Sumi-e: the object as if painted with a wet brush on rice paper, in strokes `width` (3..15) +/// across with soft, bleeding edges and rich blacks; `pressure` (0..15) loads the brush with more +/// ink and `contrast` (0..40) sets the contrast. It works in the ink (the value): the colours keep +/// their hue and saturation, and the object keeps its shape. +pub(super) fn sumi_e(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, pressure: f64, contrast: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + let smear = Smear::new(px, w, h, &place, width); + // The ink around each pixel: the object's darkness by its coverage, softened. + let mut wet: Vec = px.iter().map(|p| (1.0 - luma(straight(*p))) * f32::from(p[3]) / 255.0).collect(); + blur_plane(&mut wet, w, h, 0.2 * smear.len / place.px); + let load = 1.0 + 0.06 * pressure.clamp(0.0, 15.0); + // How far the ink moves along an S curve: up to once at 20, twice at 40. + let gain = contrast.clamp(0.0, 40.0) / 20.0; + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let q = place.at(x, y); + let (c, a) = unpremultiply(smear.stroke(SALT_SUMI, q, LEFT, (0.3 * smear.len).max(1.0))); + // Where the stroke brings no paint, the pixel's own colour. + let c = if a > 0.0 { c } else { straight(*p) }; + let l = f64::from(luma(c)); + // The ink bleeds into the paper around the darks, along its fibres. + let bleed = 0.85 * f64::from(wet.get(y * w + x).copied().unwrap_or(0.0)); + let fibre = 1.0 + 0.1 * streak(SALT_SUMI + 1, q, Vec2::new(1.0, 0.0), 2.0, 0.35); + let mut ink = ((1.0 - l).max(bleed) * load * fibre).clamp(0.0, 1.0); + let mut left = gain; + while left > 0.0 { + ink += (ramp(ink) - ink) * left.min(1.0); + left -= 1.0; + } + let tone = (1.0 - ink).clamp(0.0, 1.0); + // Darker towards black, lighter towards the paper's white: the hue stays. + let out = if tone <= l { + let k = if l > 1e-3 { (tone / l) as f32 } else { 0.0 }; + c.map(|v| v * k) + } else { + let k = ((tone - l) / (1.0 - l).max(1e-3)).clamp(0.0, 1.0) as f32; + c.map(|v| v + (1.0 - v) * k) + }; + *p = premultiply(out, f32::from(p[3]) / 255.0); + }); +} diff --git a/crates/effects/src/pixel/distort.rs b/crates/effects/src/pixel/distort.rs index fcf94056f..1f4884ffd 100644 --- a/crates/effects/src/pixel/distort.rs +++ b/crates/effects/src/pixel/distort.rs @@ -159,7 +159,7 @@ pub(super) fn diffuse_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel /// Move the object's content by `shift(q)` (document units; `q` a pixel's centre relative to the /// object's centre, its length capped at `most`): each pixel takes the colour `shift` away from it, /// interpolated between pixels, transparent beyond the raster. -fn displace(px: &mut [[u8; 4]], w: usize, h: usize, place: &Place, most: f64, shift: impl Fn(Point) -> Vec2) { +pub(super) fn displace(px: &mut [[u8; 4]], w: usize, h: usize, place: &Place, most: f64, shift: impl Fn(Point) -> Vec2) { if !(most.is_finite() && most > 0.0) { return; } diff --git a/crates/effects/src/pixel/edges.rs b/crates/effects/src/pixel/edges.rs index 25812fff1..6cacd04c4 100644 --- a/crates/effects/src/pixel/edges.rs +++ b/crates/effects/src/pixel/edges.rs @@ -21,38 +21,20 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes if alpha > 0.0 { [f32::from(p[0]) / 255.0 / alpha, f32::from(p[1]) / 255.0 / alpha, f32::from(p[2]) / 255.0 / alpha] } else { [0.0; 3] }; (straight, alpha) }; - let signal = |x: isize, y: isize| { - let (rgb, alpha) = pixel(x, y); - (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha - }; - let mut edges = vec![0.0f32; px.len()]; + let signal: Vec = (0..px.len()) + .map(|i| { + let (rgb, alpha) = pixel((i % w) as isize, (i / w) as isize); + (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha + }) + .collect(); + let coverage: Vec = src.iter().map(|p| f32::from(p[3]) / 255.0).collect(); + let mut edges: Vec = sobel(&signal, w, h).iter().zip(sobel(&coverage, w, h)).map(|(s, a)| s.max(a).clamp(0.0, 1.0)).collect(); let mut colours: [Vec; 3] = std::array::from_fn(|_| vec![0.0; px.len()]); - for y in 0..h { - for x in 0..w { - let x = x as isize; - let y = y as isize; - let gx = -signal(x - 1, y - 1) + signal(x + 1, y - 1) - 2.0 * signal(x - 1, y) + 2.0 * signal(x + 1, y) - signal(x - 1, y + 1) - + signal(x + 1, y + 1); - let gy = -signal(x - 1, y - 1) - 2.0 * signal(x, y - 1) - signal(x + 1, y - 1) - + signal(x - 1, y + 1) - + 2.0 * signal(x, y + 1) - + signal(x + 1, y + 1); - let alpha_signal = |x, y| pixel(x, y).1; - let agx = -alpha_signal(x - 1, y - 1) + alpha_signal(x + 1, y - 1) - 2.0 * alpha_signal(x - 1, y) + 2.0 * alpha_signal(x + 1, y) - - alpha_signal(x - 1, y + 1) - + alpha_signal(x + 1, y + 1); - let agy = -alpha_signal(x - 1, y - 1) - 2.0 * alpha_signal(x, y - 1) - alpha_signal(x + 1, y - 1) - + alpha_signal(x - 1, y + 1) - + 2.0 * alpha_signal(x, y + 1) - + alpha_signal(x + 1, y + 1); - let index = y as usize * w + x as usize; - let edge = gx.hypot(gy).max(agx.hypot(agy)).clamp(0.0, 1.0); - if let Some(value) = edges.get_mut(index) { - *value = edge; - let (rgb, alpha) = pixel(x, y); - for channel in 0..3 { - colours[channel][index] = rgb[channel] * alpha * edge; - } + for (index, edge) in edges.iter().enumerate() { + let (rgb, alpha) = pixel((index % w) as isize, (index / w) as isize); + for (colour, v) in colours.iter_mut().zip(rgb) { + if let Some(c) = colour.get_mut(index) { + *c = v * alpha * edge; } } } @@ -85,3 +67,25 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes ]; } } + +/// The Sobel gradient's length over a `w` × `h` plane of values (zero beyond its edges): 8 × the +/// slope per pixel on a ramp, 4 × the step at a sharp edge. Empty when the sizes don't match. +pub(super) fn sobel(v: &[f32], w: usize, h: usize) -> Vec { + if w == 0 || w.checked_mul(h) != Some(v.len()) { + return Vec::new(); + } + let at = |x: usize, dx: isize, y: usize, dy: isize| -> f32 { + match (x.checked_add_signed(dx), y.checked_add_signed(dy)) { + (Some(x), Some(y)) if x < w && y < h => v.get(y * w + x).copied().unwrap_or(0.0), + _ => 0.0, + } + }; + (0..v.len()) + .map(|i| { + let (x, y) = (i % w, i / w); + let gx = at(x, 1, y, -1) - at(x, -1, y, -1) + 2.0 * (at(x, 1, y, 0) - at(x, -1, y, 0)) + at(x, 1, y, 1) - at(x, -1, y, 1); + let gy = at(x, -1, y, 1) - at(x, -1, y, -1) + 2.0 * (at(x, 0, y, 1) - at(x, 0, y, -1)) + at(x, 1, y, 1) - at(x, 1, y, -1); + gx.hypot(gy) + }) + .collect() +} diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index 62592768d..77cc0b620 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -1,8 +1,9 @@ -//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Distort › Diffuse -//! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, -//! Sharpen › Unsharp Mask, Stylize › Glowing -//! Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and -//! Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels. +//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes › +//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes +//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, +//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture +//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video › +//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels. //! //! - Every distance is in document units (points) and becomes pixels through the raster's //! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects @@ -12,6 +13,7 @@ //! - Beyond the raster's edges is transparency. mod blur; +mod brushstrokes; mod distort; mod edges; mod pixelate; @@ -24,14 +26,23 @@ use vectorcraft_geom::{Affine, Point, Rect}; use crate::util::{flag, num, text}; +pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection}; pub use distort::{GLASS_TEXTURES, GlassTexture}; pub use pixelate::{MEZZOTINT_TYPES, Mezzotint}; pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture}; /// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]). -pub const PIXEL_EFFECTS: [&str; 19] = [ +pub const PIXEL_EFFECTS: [&str; 27] = [ "blur.radial", "blur.smart", + "brushStrokes.accentedEdges", + "brushStrokes.angledStrokes", + "brushStrokes.crosshatch", + "brushStrokes.darkStrokes", + "brushStrokes.inkOutlines", + "brushStrokes.spatter", + "brushStrokes.sprayedStrokes", + "brushStrokes.sumiE", "distort.diffuseGlow", "distort.glass", "distort.oceanRipple", @@ -62,6 +73,32 @@ pub enum PixelFx { /// whose colour differs from its own by no more than `threshold` levels, over a grid of /// `samples` × `samples` neighbours at most. SmartBlur { radius: f64, threshold: f64, samples: u32 }, + /// Brush Strokes › Accented Edges: the edges, about `width` (1..14) wide, accented towards + /// white chalk or black ink as `brightness` (0..50) says; `smoothness` (1..15) softens them. + AccentedEdges { width: f64, brightness: f64, smoothness: f64 }, + /// Brush Strokes › Angled Strokes: diagonal strokes `length` long, the light areas' and the + /// dark areas' going opposite ways, split as `balance` (0..100) says, as crisp as `sharpness` + /// (0..10). + AngledStrokes { balance: f64, length: f64, sharpness: f64 }, + /// Brush Strokes › Crosshatch: pencil hatching `length` long along both diagonals, as crisp + /// as `sharpness` (0..20), in `strength` (1..3) passes. + Crosshatch { length: f64, sharpness: f64, strength: u32 }, + /// Brush Strokes › Dark Strokes: short strokes towards black in the dark areas, long ones + /// towards white in the light areas, split by `balance` (0..10), as dark as `black` (0..10) + /// and as light as `white` (0..10). + DarkStrokes { balance: f64, black: f64, white: f64 }, + /// Brush Strokes › Ink Outlines: pen-and-ink outlines and strokes `length` long, the shadows + /// darkened by `dark` (0..50) and the highlights lightened by `light` (0..50). + InkOutlines { length: f64, dark: f64, light: f64 }, + /// Brush Strokes › Spatter: a spatter airbrush spraying within `radius` (0..25), its grains as + /// smooth as `smoothness` (1..15). + Spatter { radius: f64, smoothness: f64 }, + /// Brush Strokes › Sprayed Strokes: sprayed strokes `length` long along `direction`, scattered + /// as `radius` (0..25) says. + SprayedStrokes { length: f64, radius: f64, direction: StrokeDirection }, + /// Brush Strokes › Sumi-e: wet black brush strokes `width` (3..15) across on rice paper, as + /// loaded as `pressure` (0..15) says, with `contrast` (0..40). + SumiE { width: f64, pressure: f64, contrast: f64 }, /// Distort › Diffuse Glow: the highlights glow white as strongly as `glow` (0..20), from the /// brightness `clear` (0..20) leaves clear, under white grain as dense as `graininess` (0..10). DiffuseGlow { graininess: f64, glow: f64, clear: f64 }, @@ -165,6 +202,44 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0), samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7), }, + "brushStrokes.accentedEdges" => PixelFx::AccentedEdges { + width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0), + brightness: num(p, "edgeBrightness", 38.0).clamp(0.0, 50.0), + smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0), + }, + "brushStrokes.angledStrokes" => PixelFx::AngledStrokes { + balance: num(p, "directionBalance", 50.0).clamp(0.0, 100.0), + length: num(p, "strokeLength", 15.0).clamp(3.0, 50.0), + sharpness: num(p, "sharpness", 3.0).clamp(0.0, 10.0), + }, + "brushStrokes.crosshatch" => PixelFx::Crosshatch { + length: num(p, "strokeLength", 9.0).clamp(3.0, 50.0), + sharpness: num(p, "sharpness", 6.0).clamp(0.0, 20.0), + strength: num(p, "strength", 1.0).clamp(1.0, 3.0).round() as u32, + }, + "brushStrokes.darkStrokes" => PixelFx::DarkStrokes { + balance: num(p, "balance", 5.0).clamp(0.0, 10.0), + black: num(p, "blackIntensity", 6.0).clamp(0.0, 10.0), + white: num(p, "whiteIntensity", 2.0).clamp(0.0, 10.0), + }, + "brushStrokes.inkOutlines" => PixelFx::InkOutlines { + length: num(p, "strokeLength", 4.0).clamp(1.0, 50.0), + dark: num(p, "darkIntensity", 20.0).clamp(0.0, 50.0), + light: num(p, "lightIntensity", 10.0).clamp(0.0, 50.0), + }, + "brushStrokes.spatter" => { + PixelFx::Spatter { radius: num(p, "sprayRadius", 10.0).clamp(0.0, 25.0), smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0) } + } + "brushStrokes.sprayedStrokes" => PixelFx::SprayedStrokes { + length: num(p, "strokeLength", 12.0).clamp(0.0, 20.0), + radius: num(p, "sprayRadius", 7.0).clamp(0.0, 25.0), + direction: StrokeDirection::parse(text(p, "strokeDirection", "rightDiagonal")), + }, + "brushStrokes.sumiE" => PixelFx::SumiE { + width: num(p, "strokeWidth", 10.0).clamp(3.0, 15.0), + pressure: num(p, "strokePressure", 2.0).clamp(0.0, 15.0), + contrast: num(p, "contrast", 16.0).clamp(0.0, 40.0), + }, "distort.diffuseGlow" => PixelFx::DiffuseGlow { graininess: num(p, "graininess", 6.0).clamp(0.0, 10.0), glow: num(p, "glowAmount", 10.0).clamp(0.0, 20.0), @@ -248,6 +323,15 @@ impl PixelFx { PixelFx::RadialBlur { zoom: false, .. } => (far - near).max(0.0), PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0), PixelFx::SmartBlur { radius, .. } => radius, + PixelFx::AccentedEdges { .. } => 0.0, + // Strokes reach out of the object as far as they reach into it. + PixelFx::AngledStrokes { length, .. } | PixelFx::InkOutlines { length, .. } => brushstrokes::stroke_reach(length), + PixelFx::Crosshatch { length, .. } => brushstrokes::stroke_reach(length) + brushstrokes::rough(length), + PixelFx::DarkStrokes { .. } => brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH), + PixelFx::Spatter { radius, .. } => brushstrokes::spatter_shift(radius), + PixelFx::SprayedStrokes { length, radius, .. } => brushstrokes::stroke_reach(length) + brushstrokes::spray_shift(radius), + // Sumi-e keeps the object's shape. + PixelFx::SumiE { .. } => 0.0, PixelFx::DiffuseGlow { .. } => 0.0, // Content shifted outwards reaches past the bounds by as much as the shift. PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion), @@ -275,6 +359,17 @@ impl PixelFx { match *self { PixelFx::RadialBlur { .. } => None, PixelFx::SmartBlur { radius, .. } => Some(radius), + PixelFx::AccentedEdges { width, smoothness, .. } => Some(brushstrokes::accent_reach(width, smoothness)), + // The strokes, and the softened tones that choose them. + PixelFx::AngledStrokes { length, .. } => Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH)), + PixelFx::Crosshatch { length, .. } => { + Some((brushstrokes::stroke_reach(length) + brushstrokes::rough(length)).max(brushstrokes::TONE_REACH)) + } + PixelFx::DarkStrokes { .. } => Some(brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH).max(brushstrokes::TONE_REACH)), + PixelFx::InkOutlines { length, .. } => Some(brushstrokes::ink_reach(length)), + PixelFx::Spatter { radius, .. } => Some(brushstrokes::spatter_shift(radius)), + PixelFx::SprayedStrokes { length, radius, .. } => Some(brushstrokes::stroke_reach(length) + brushstrokes::spray_shift(radius)), + PixelFx::SumiE { width, .. } => Some(brushstrokes::sumi_reach(width)), PixelFx::DiffuseGlow { .. } => Some(3.0 * distort::GLOW_SPREAD), PixelFx::Glass { distortion, .. } => Some(distort::glass_shift(distortion)), PixelFx::OceanRipple { size, magnitude } => Some(distort::ripple_shift(size, magnitude)), @@ -306,6 +401,14 @@ impl PixelFx { match *self { PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes), PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples), + PixelFx::AccentedEdges { width, brightness, smoothness } => brushstrokes::accented_edges(px, w, h, space, width, brightness, smoothness), + PixelFx::AngledStrokes { balance, length, sharpness } => brushstrokes::angled_strokes(px, w, h, space, balance, length, sharpness), + PixelFx::Crosshatch { length, sharpness, strength } => brushstrokes::crosshatch(px, w, h, space, length, sharpness, strength), + PixelFx::DarkStrokes { balance, black, white } => brushstrokes::dark_strokes(px, w, h, space, balance, black, white), + PixelFx::InkOutlines { length, dark, light } => brushstrokes::ink_outlines(px, w, h, space, length, dark, light), + PixelFx::Spatter { radius, smoothness } => brushstrokes::spatter(px, w, h, space, radius, smoothness), + PixelFx::SprayedStrokes { length, radius, direction } => brushstrokes::sprayed_strokes(px, w, h, space, length, radius, direction), + PixelFx::SumiE { width, pressure, contrast } => brushstrokes::sumi_e(px, w, h, space, width, pressure, contrast), PixelFx::DiffuseGlow { graininess, glow, clear } => distort::diffuse_glow(px, w, h, space, graininess, glow, clear), PixelFx::Glass { distortion, smoothness, texture, scaling, invert } => { distort::glass(px, w, h, space, distortion, smoothness, texture, scaling, invert) diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 2de43c0e4..f2b8a7843 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -276,6 +276,14 @@ fn pixel_timings() { ("blur.smart", json!({"quality": "low"})), ("blur.smart", json!({"quality": "high", "radius": 30})), ("sharpen.unsharpMask", json!({"radius": 20})), + ("brushStrokes.accentedEdges", json!({"edgeWidth": 14})), + ("brushStrokes.angledStrokes", json!({"strokeLength": 50})), + ("brushStrokes.crosshatch", json!({"strokeLength": 50, "strength": 3})), + ("brushStrokes.darkStrokes", json!({})), + ("brushStrokes.inkOutlines", json!({"strokeLength": 50})), + ("brushStrokes.spatter", json!({"sprayRadius": 25})), + ("brushStrokes.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25})), + ("brushStrokes.sumiE", json!({"strokeWidth": 15})), ("pixelate.colorHalftone", json!({"maxRadius": 4})), ("pixelate.crystallize", json!({"cellSize": 3})), ("pixelate.crystallize", json!({"cellSize": 300})), @@ -982,3 +990,322 @@ fn distort_patterns_follow_the_object_at_any_resolution() { assert_eq!(shifted, one, "{id}"); } } + +/// The Brush Strokes effect ids. +fn brush_strokes() -> impl Iterator { + PIXEL_EFFECTS.into_iter().filter(|id| id.starts_with("brushStrokes.")) +} + +#[test] +fn brush_strokes_params_take_defaults_and_stay_in_range() { + assert_eq!(fx("brushStrokes.accentedEdges", json!({})), PixelFx::AccentedEdges { width: 2.0, brightness: 38.0, smoothness: 5.0 }); + assert_eq!( + fx("brushStrokes.accentedEdges", json!({"edgeWidth": 99, "edgeBrightness": -1, "smoothness": "x"})), + PixelFx::AccentedEdges { width: 14.0, brightness: 0.0, smoothness: 5.0 } + ); + assert_eq!(fx("brushStrokes.angledStrokes", json!({})), PixelFx::AngledStrokes { balance: 50.0, length: 15.0, sharpness: 3.0 }); + assert_eq!( + fx("brushStrokes.angledStrokes", json!({"directionBalance": 1e308, "strokeLength": 0, "sharpness": 11})), + PixelFx::AngledStrokes { balance: 100.0, length: 3.0, sharpness: 10.0 } + ); + assert_eq!(fx("brushStrokes.crosshatch", json!({})), PixelFx::Crosshatch { length: 9.0, sharpness: 6.0, strength: 1 }); + assert_eq!( + fx("brushStrokes.crosshatch", json!({"strokeLength": 51, "sharpness": -1, "strength": 2.6})), + PixelFx::Crosshatch { length: 50.0, sharpness: 0.0, strength: 3 } + ); + assert_eq!(fx("brushStrokes.crosshatch", json!({"strength": -7})), PixelFx::Crosshatch { length: 9.0, sharpness: 6.0, strength: 1 }); + assert_eq!(fx("brushStrokes.darkStrokes", json!({})), PixelFx::DarkStrokes { balance: 5.0, black: 6.0, white: 2.0 }); + assert_eq!( + fx("brushStrokes.darkStrokes", json!({"balance": 11, "blackIntensity": -1, "whiteIntensity": 1e9})), + PixelFx::DarkStrokes { balance: 10.0, black: 0.0, white: 10.0 } + ); + assert_eq!(fx("brushStrokes.inkOutlines", json!({})), PixelFx::InkOutlines { length: 4.0, dark: 20.0, light: 10.0 }); + assert_eq!( + fx("brushStrokes.inkOutlines", json!({"strokeLength": 0, "darkIntensity": 51, "lightIntensity": -5})), + PixelFx::InkOutlines { length: 1.0, dark: 50.0, light: 0.0 } + ); + assert_eq!(fx("brushStrokes.spatter", json!({})), PixelFx::Spatter { radius: 10.0, smoothness: 5.0 }); + assert_eq!(fx("brushStrokes.spatter", json!({"sprayRadius": 26, "smoothness": 0})), PixelFx::Spatter { radius: 25.0, smoothness: 1.0 }); + assert_eq!( + fx("brushStrokes.sprayedStrokes", json!({})), + PixelFx::SprayedStrokes { length: 12.0, radius: 7.0, direction: StrokeDirection::RightDiagonal } + ); + assert_eq!( + fx("brushStrokes.sprayedStrokes", json!({"strokeLength": -1, "sprayRadius": 1e308, "strokeDirection": "VERTICAL"})), + PixelFx::SprayedStrokes { length: 0.0, radius: 25.0, direction: StrokeDirection::Vertical } + ); + assert_eq!(fx("brushStrokes.sprayedStrokes", json!({"strokeDirection": "diagonal"})), fx("brushStrokes.sprayedStrokes", json!({}))); + assert_eq!(fx("brushStrokes.sumiE", json!({})), PixelFx::SumiE { width: 10.0, pressure: 2.0, contrast: 16.0 }); + assert_eq!( + fx("brushStrokes.sumiE", json!({"strokeWidth": 2, "strokePressure": 16, "contrast": -1})), + PixelFx::SumiE { width: 3.0, pressure: 15.0, contrast: 0.0 } + ); + // Outsets: the strokes and the shifts reach out of the object as far as into it; Accented + // Edges and Sumi-e keep the object's shape. + let b = Rect::new(0.0, 0.0, 100.0, 50.0); + let reach = brushstrokes::stroke_reach; + assert!((reach(20.0) - (0.75 * 20.0 + 1.5 / 15.0 * 20.0 + 1.0)).abs() < 1e-9); + assert_eq!(fx("brushStrokes.accentedEdges", json!({})).outset(b), 0.0); + assert_eq!(fx("brushStrokes.accentedEdges", json!({})).reach(), Some(3.0 * (0.35 * 2.0 + 0.5) + 1.0)); + assert_eq!(fx("brushStrokes.angledStrokes", json!({})).outset(b), reach(15.0)); + assert_eq!(fx("brushStrokes.crosshatch", json!({})).outset(b), reach(9.0) + 0.9); + assert_eq!(fx("brushStrokes.darkStrokes", json!({})).outset(b), reach(9.0)); + assert_eq!(fx("brushStrokes.inkOutlines", json!({"strokeLength": 50})).outset(b), reach(50.0)); + let spatter = fx("brushStrokes.spatter", json!({})); + assert_eq!((spatter.outset(b), spatter.reach()), (4.0, Some(4.0))); + assert_eq!(fx("brushStrokes.spatter", json!({"sprayRadius": 0})).outset(b), 0.0); + assert_eq!(fx("brushStrokes.sprayedStrokes", json!({})).outset(b), reach(12.0) + 0.3 * 7.0); + assert_eq!(fx("brushStrokes.sumiE", json!({})).outset(b), 0.0); + for id in brush_strokes() { + let f = fx(id, json!({})); + assert!(f.reach().is_some_and(|r| r >= f.outset(b)), "{id}"); + } +} + +/// A light square in a darker ground with a ramp across it, over the whole raster: edges, +/// shadows and highlights for the strokes to work on. +fn scene(w: usize, h: usize) -> Vec { + image(w, h, |x, y| if (12..36).contains(&x) && (12..36).contains(&y) { [235, 220, 190, 255] } else { [(30 + x) as u8, 40, 90, 255] }) +} + +/// `src` (`w` × `h`) through effect `id` with parameters `p`. +fn run_fx(src: &[u8], w: usize, h: usize, id: &str, p: serde_json::Value) -> Vec { + let mut d = src.to_vec(); + fx(id, p).apply(&mut d, w, h, &space(w, h)); + d +} + +#[test] +fn brush_strokes_each_repaint_the_art_their_own_way() { + let (w, h) = (48, 48); + let src = scene(w, h); + let mut seen: Vec<(&str, Vec)> = vec![]; + for id in brush_strokes() { + let d = run_fx(&src, w, h, id, json!({})); + assert_ne!(d, src, "{id} changes the art"); + for (other, o) in &seen { + assert_ne!(&d, o, "{id} differs from {other}"); + } + seen.push((id, d)); + } + assert_eq!(seen.len(), 8); + // Each stroke direction paints its own way. + let mut ways: Vec> = vec![]; + for (_, way) in STROKE_DIRECTIONS { + let d = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"strokeDirection": way})); + assert!(!ways.contains(&d), "{way} differs from the other directions"); + ways.push(d); + } + // Every option shows. + for (id, a, b) in [ + ("brushStrokes.accentedEdges", json!({"edgeWidth": 1}), json!({"edgeWidth": 14})), + ("brushStrokes.accentedEdges", json!({"smoothness": 1}), json!({"smoothness": 15})), + ("brushStrokes.angledStrokes", json!({"directionBalance": 0}), json!({"directionBalance": 100})), + ("brushStrokes.angledStrokes", json!({"strokeLength": 3}), json!({"strokeLength": 50})), + ("brushStrokes.angledStrokes", json!({"sharpness": 0}), json!({"sharpness": 10})), + ("brushStrokes.crosshatch", json!({"strokeLength": 3}), json!({"strokeLength": 50})), + ("brushStrokes.crosshatch", json!({"sharpness": 0}), json!({"sharpness": 20})), + ("brushStrokes.crosshatch", json!({"strength": 1}), json!({"strength": 3})), + ("brushStrokes.darkStrokes", json!({"balance": 0}), json!({"balance": 10})), + ("brushStrokes.darkStrokes", json!({"blackIntensity": 0}), json!({"blackIntensity": 10})), + ("brushStrokes.darkStrokes", json!({"whiteIntensity": 0}), json!({"whiteIntensity": 10})), + ("brushStrokes.inkOutlines", json!({"strokeLength": 1}), json!({"strokeLength": 50})), + ("brushStrokes.inkOutlines", json!({"darkIntensity": 0}), json!({"darkIntensity": 50})), + ("brushStrokes.inkOutlines", json!({"lightIntensity": 0}), json!({"lightIntensity": 50})), + ("brushStrokes.spatter", json!({"sprayRadius": 5}), json!({"sprayRadius": 25})), + ("brushStrokes.spatter", json!({"smoothness": 1}), json!({"smoothness": 15})), + ("brushStrokes.sprayedStrokes", json!({"strokeLength": 0}), json!({"strokeLength": 20})), + ("brushStrokes.sprayedStrokes", json!({"sprayRadius": 0}), json!({"sprayRadius": 25})), + ("brushStrokes.sumiE", json!({"strokeWidth": 3}), json!({"strokeWidth": 15})), + ("brushStrokes.sumiE", json!({"strokePressure": 0}), json!({"strokePressure": 15})), + ("brushStrokes.sumiE", json!({"contrast": 0}), json!({"contrast": 40})), + ] { + assert_ne!(run_fx(&src, w, h, id, a.clone()), run_fx(&src, w, h, id, b.clone()), "{id}: {a} and {b}"); + } +} + +/// The mean of the colour channels over columns `xs` and rows `ys` (0..255). +fn mean_tone(d: &[u8], w: usize, xs: std::ops::Range, ys: std::ops::Range) -> f64 { + let px: Vec<[u8; 4]> = ys.flat_map(|y| xs.clone().map(move |x| (x, y))).map(|(x, y)| at(d, w, x, y)).collect(); + px.iter().map(|p| f64::from(p[0]) + f64::from(p[1]) + f64::from(p[2])).sum::() / (3.0 * px.len().max(1) as f64) +} + +/// Dark on the left, light on the right, opaque over the whole raster. +fn halves(w: usize, h: usize) -> Vec { + image(w, h, |x, _| if x < w / 2 { [50, 60, 100, 255] } else { [210, 200, 180, 255] }) +} + +#[test] +fn accented_edges_draw_chalk_or_ink_on_the_edges_only() { + let (w, h) = (48, 48); + // The halves inside a transparent margin. + let both = halves(w, h); + let src = image(w, h, |x, y| if (4..44).contains(&x) && (4..44).contains(&y) { at(&both, w, x, y) } else { [0; 4] }); + let chalk = run_fx(&src, w, h, "brushStrokes.accentedEdges", json!({"edgeBrightness": 50})); + let ink = run_fx(&src, w, h, "brushStrokes.accentedEdges", json!({"edgeBrightness": 0})); + let sum = |p: [u8; 4]| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]); + // Along the edge between the halves: white chalk on the dark side, black ink on the light. + assert!(sum(at(&chalk, w, 23, 24)) > sum(at(&src, w, 23, 24)) + 150, "{:?}", at(&chalk, w, 23, 24)); + assert!(sum(at(&ink, w, 24, 24)) + 150 < sum(at(&src, w, 24, 24)), "{:?}", at(&ink, w, 24, 24)); + // Away from the edges nothing changes, and the object keeps its shape. + for (x, y) in [(13, 24), (34, 24), (13, 13)] { + assert_eq!(at(&chalk, w, x, y), at(&src, w, x, y), "({x}, {y})"); + } + for (a, b) in chalk.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) { + assert_eq!(a[3], b[3]); + } +} + +#[test] +fn dark_and_ink_strokes_darken_the_shadows_and_sumi_e_inks_them_black() { + let (w, h) = (64, 48); + let src = halves(w, h); + let (shadow, light) = (8..24, 40..56); + let tone = |d: &[u8], xs: std::ops::Range| mean_tone(d, w, xs, 8..40); + let dark = run_fx(&src, w, h, "brushStrokes.darkStrokes", json!({})); + assert!(tone(&dark, shadow.clone()) < tone(&src, shadow.clone()) - 20.0, "dark strokes in the shadows"); + assert!(tone(&dark, light.clone()) > tone(&src, light.clone()) + 5.0, "white strokes in the light"); + let ink = run_fx(&src, w, h, "brushStrokes.inkOutlines", json!({})); + assert!(tone(&ink, shadow.clone()) < tone(&src, shadow.clone()) - 10.0, "ink in the shadows"); + // The outline along the edge is darker than the light side around it. + assert!(tone(&ink, 32..34) + 40.0 < tone(&ink, light.clone()), "an ink outline along the edge"); + let sumi = run_fx(&src, w, h, "brushStrokes.sumiE", json!({})); + assert!(tone(&sumi, shadow.clone()) < 0.4 * tone(&src, shadow.clone()), "rich blacks: {}", tone(&sumi, shadow.clone())); + assert!(tone(&sumi, 50..56) > 150.0, "the light stays light: {}", tone(&sumi, 50..56)); +} + +#[test] +fn sumi_e_keeps_the_shape_and_the_hues() { + let (w, h) = (48, 48); + // A strong yellow square and a dark blue one in a transparent margin. + let src = image(w, h, |x, y| match (x, y) { + (8..24, 8..40) => [230, 200, 40, 255], + (24..40, 8..40) => [30, 40, 120, 255], + _ => [0; 4], + }); + let d = run_fx(&src, w, h, "brushStrokes.sumiE", json!({"contrast": 40, "strokePressure": 15})); + // Transparency stays transparent and the coverage stays as it was: no fringe. + for (a, b) in d.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) { + assert_eq!(a[3], b[3]); + if b[3] == 0 { + assert_eq!(*a, [0; 4]); + } + } + // The yellow stays yellow, no more saturated than it was. + let sat = |p: [u8; 4]| { + let (hi, lo) = (p[..3].iter().max().copied().unwrap(), p[..3].iter().min().copied().unwrap()); + f64::from(hi - lo) / f64::from(hi.max(1)) + }; + let y = at(&d, w, 12, 24); + assert!(y[0] > y[2] + 80 && y[1] > y[2] + 80, "{y:?}"); + assert!(sat(y) <= sat([230, 200, 40, 255]) + 0.02, "{y:?}"); + // The dark blue is inked darker, still blue. + let b = at(&d, w, 34, 24); + assert!(b[2] < 120 && b[2] >= b[0] && b[2] >= b[1], "{b:?}"); +} + +#[test] +fn crosshatch_hatches_flat_colour_and_keeps_its_tone() { + let (w, h) = (48, 48); + let src = image(w, h, |_, _| grey(128)); + let spread = |d: &[u8]| { + let m = mean_tone(d, w, 8..40, 8..40); + let var = (8..40).flat_map(|x| (8..40).map(move |y| (x, y))).map(|(x, y)| (f64::from(at(d, w, x, y)[0]) - m).powi(2)).sum::() / 1024.0; + (m, var.sqrt()) + }; + let (one, three) = + (run_fx(&src, w, h, "brushStrokes.crosshatch", json!({})), run_fx(&src, w, h, "brushStrokes.crosshatch", json!({"strength": 3}))); + let ((m1, s1), (_, s3)) = (spread(&one), spread(&three)); + assert!((m1 - 128.0).abs() < 25.0 && s1 > 3.0, "{m1} ± {s1}"); + assert!(s3 > s1, "more passes, more hatching: {s3} vs {s1}"); +} + +#[test] +fn stroke_directions_smear_along_themselves() { + let (w, h) = (48, 48); + // A white line two pixels thick across black. + let src = image(w, h, |_, y| if (23..25).contains(&y) { grey(255) } else { grey(0) }); + let row = |d: &[u8], y: usize| mean_tone(d, w, 12..36, y..y + 1); + let along = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0, "strokeDirection": "horizontal"})); + let across = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0, "strokeDirection": "vertical"})); + assert!(row(&along, 20) < 1.0 && row(&along, 24) > 250.0, "strokes along the line keep it"); + assert!(row(&across, 20) > 15.0 && row(&across, 24) < 200.0, "strokes across it spread it: {} {}", row(&across, 20), row(&across, 24)); + // Diagonal strokes spread it too, along the diagonal. + let diagonal = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0})); + assert!(row(&diagonal, 20) > 5.0); +} + +#[test] +fn spatter_scatters_content_no_farther_than_its_radius() { + let (w, h) = (64, 32); + // The left half white, the right half transparent. + let src = image(w, h, |x, _| if x < 32 { grey(255) } else { [0; 4] }); + assert_eq!(run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 0})), src, "no radius, no spatter"); + let f = fx("brushStrokes.spatter", json!({"sprayRadius": 15})); + let most = f.reach().unwrap(); + let d = run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 15})); + assert_ne!(d, src); + let inside = |v: usize, n: usize| (v as f64 + 0.5).min(n as f64 - v as f64 - 0.5) > most + 1.0; + for y in (0..h).filter(|y| inside(*y, h)) { + for x in (0..w).filter(|x| inside(*x, w) && (*x as f64 + 0.5 - 32.0).abs() > most + 1.0) { + assert_eq!(at(&d, w, x, y), at(&src, w, x, y), "({x}, {y})"); + } + } + // The edge is spattered: white specks past it and holes before it. + let past = (33..36).flat_map(|x| (8..24).map(move |y| (x, y))).filter(|(x, y)| at(&d, w, *x, *y)[3] > 127).count(); + let holes = (28..31).flat_map(|x| (8..24).map(move |y| (x, y))).filter(|(x, y)| at(&d, w, *x, *y)[3] < 128).count(); + assert!(past > 0 && holes > 0, "{past} specks past the edge, {holes} holes before it"); +} + +#[test] +fn brush_strokes_look_no_farther_than_their_reach() { + let (w, h) = (200, 24); + let base = image(w, h, |x, y| [(x * 7 % 256) as u8, (y * 11 % 256) as u8, 120, 255]); + let cases = brush_strokes().map(|id| (id, json!({}))).chain([ + ("brushStrokes.accentedEdges", json!({"edgeWidth": 14, "smoothness": 15})), + ("brushStrokes.angledStrokes", json!({"strokeLength": 50})), + ("brushStrokes.crosshatch", json!({"strokeLength": 50, "strength": 3})), + ("brushStrokes.inkOutlines", json!({"strokeLength": 50})), + ("brushStrokes.spatter", json!({"sprayRadius": 25, "smoothness": 15})), + ("brushStrokes.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25, "strokeDirection": "horizontal"})), + ("brushStrokes.sumiE", json!({"strokeWidth": 15})), + ]); + for (id, p) in cases { + let reach = fx(id, p.clone()).reach().unwrap(); + // Everything from `cut` on differs; the pixels left of 20 can't tell. + let cut = 20 + reach.ceil() as usize + 1; + assert!(cut < w, "{id}"); + let other = image(w, h, |x, y| if x >= cut { [255 - (x * 5 % 256) as u8, 30, (y * 9 % 256) as u8, 200] } else { at(&base, w, x, y) }); + let (a, b) = (run_fx(&base, w, h, id, p.clone()), run_fx(&other, w, h, id, p.clone())); + for (x, y) in (0..20).flat_map(|x| (0..h).map(move |y| (x, y))) { + let (pa, pb) = (at(&a, w, x, y), at(&b, w, x, y)); + assert!(pa.iter().zip(pb).all(|(u, v)| u.abs_diff(v) <= 2), "{id} {p}: ({x}, {y}) {pa:?} vs {pb:?}, reach {reach}"); + } + } +} + +#[test] +fn brush_strokes_follow_the_object_at_any_resolution() { + let (w, h) = (48, 48); + let art = |x: f64, y: f64| -> [u8; 4] { + if (8.0..40.0).contains(&x) && (8.0..40.0).contains(&y) { [(x * 5.0) as u8, (y * 5.0) as u8, 120, 255] } else { [0; 4] } + }; + for id in brush_strokes() { + let f = fx(id, json!({})); + let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5)); + f.apply(&mut one, w, h, &space(w, h)); + let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0)); + let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) }; + f.apply(&mut two, 2 * w, 2 * h, &fine); + let down = image(w, h, |x, y| { + let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy)); + std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::() / 4) as u8) + }); + let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::() / one.len() as f64; + assert!(diff < 12.0, "{id}: {diff}"); + let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(31.0, 27.0), ..space(w, h) }; + let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5)); + f.apply(&mut shifted, w, h, &moved); + assert_eq!(shifted, one, "{id}"); + } +} diff --git a/crates/effects/src/pixel/texture.rs b/crates/effects/src/pixel/texture.rs index ead50a7f9..9855aa3cc 100644 --- a/crates/effects/src/pixel/texture.rs +++ b/crates/effects/src/pixel/texture.rs @@ -233,14 +233,14 @@ fn bevel(d: f64, width: f64) -> f64 { /// The object's straight luminance (0..1) softened by a Gaussian of `sigma` pixels, weighted by /// coverage so the transparency around it doesn't darken its edges. -struct Tones { +pub(super) struct Tones { v: Vec, w: usize, h: usize, } impl Tones { - fn new(px: &[[u8; 4]], w: usize, h: usize, sigma: f64) -> Self { + pub(super) fn new(px: &[[u8; 4]], w: usize, h: usize, sigma: f64) -> Self { let mut l: Vec = px.iter().map(|p| luma([p[0], p[1], p[2]].map(f32::from)) / 255.0).collect(); let mut a: Vec = px.iter().map(|p| f32::from(p[3]) / 255.0).collect(); blur_plane(&mut l, w, h, sigma); @@ -249,7 +249,7 @@ impl Tones { Self { v, w, h } } - fn get(&self, x: usize, y: usize) -> f64 { + pub(super) fn get(&self, x: usize, y: usize) -> f64 { let (x, y) = (x.min(self.w.saturating_sub(1)), y.min(self.h.saturating_sub(1))); self.v.get(y * self.w + x).map_or(0.0, |v| f64::from(*v)) } diff --git a/crates/engine/src/tests_rasterfilters.rs b/crates/engine/src/tests_rasterfilters.rs index a32bd7094..90f6d2b5c 100644 --- a/crates/engine/src/tests_rasterfilters.rs +++ b/crates/engine/src/tests_rasterfilters.rs @@ -1,5 +1,5 @@ -//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Distort › -//! Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture › +//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes, +//! Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture › //! Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, Video): applied and //! edited as commands, listed in the catalogue, drawn on the canvas, and written to PDF and SVG //! as images of the effected object. @@ -18,6 +18,18 @@ const EFFECTS: [(&str, &str); 3] = [ ("sharpen.unsharpMask", r#"{"amount": 300, "radius": 3}"#), ]; +/// Effect › Brush Strokes, at their defaults. +const BRUSH_STROKES: [(&str, &str); 8] = [ + ("brushStrokes.accentedEdges", "{}"), + ("brushStrokes.angledStrokes", "{}"), + ("brushStrokes.crosshatch", "{}"), + ("brushStrokes.darkStrokes", "{}"), + ("brushStrokes.inkOutlines", "{}"), + ("brushStrokes.spatter", "{}"), + ("brushStrokes.sprayedStrokes", "{}"), + ("brushStrokes.sumiE", "{}"), +]; + /// Effect › Distort (Diffuse Glow with its highlights glowing from a low brightness up, as the /// striped art's reds are dark). const DISTORT: [(&str, &str); 3] = @@ -78,6 +90,7 @@ fn listed_applied_edited_and_undone() { EFFECTS.iter().map(|(id, _)| list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap()["menu"].clone()).collect(); assert_eq!(menus, [json!(["Effect", "Blur"]), json!(["Effect", "Blur"]), json!(["Effect", "Sharpen"])]); for (group, menu) in [ + (BRUSH_STROKES.as_slice(), "Brush Strokes"), (DISTORT.as_slice(), "Distort"), (PIXELATE.as_slice(), "Pixelate"), (TEXTURE.as_slice(), "Texture"), @@ -108,7 +121,7 @@ fn listed_applied_edited_and_undone() { #[test] fn each_effect_changes_the_canvas_and_draws_the_same_twice() { - for (id, params) in EFFECTS.into_iter().chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) { + for (id, params) in EFFECTS.into_iter().chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) { let (mut s, g) = striped(); let plain = render(&s); let params: Value = serde_json::from_str(params).unwrap(); @@ -137,7 +150,7 @@ fn spin_blur_reaches_past_the_corners() { #[test] fn pdf_and_svg_write_the_effected_object_as_an_image() { - for (id, params) in EFFECTS.into_iter().chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) { + for (id, params) in EFFECTS.into_iter().chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) { let (mut s, g) = striped(); let params: Value = serde_json::from_str(params).unwrap(); s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap(); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 1ad18115d..d603ca885 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -292,6 +292,26 @@ fn structured_junk() { ("effect.apply", json!({"effect": "blur.radial", "params": {"amount": 1e308, "method": 5, "quality": "best"}})), ("effect.apply", json!({"effect": "blur.smart", "params": {"radius": 1e308, "threshold": -1e308, "quality": null}})), ("effect.apply", json!({"effect": "sharpen.unsharpMask", "params": {"amount": -1, "radius": "1e999", "threshold": 1e308}})), + ( + "effect.apply", + json!({"effect": "brushStrokes.accentedEdges", "params": {"edgeWidth": 1e308, "edgeBrightness": "NaN", "smoothness": -1e308}}), + ), + ( + "effect.apply", + json!({"effect": "brushStrokes.angledStrokes", "params": {"directionBalance": [1], "strokeLength": 1e308, "sharpness": null}}), + ), + ("effect.apply", json!({"effect": "brushStrokes.crosshatch", "params": {"strokeLength": "1e999", "sharpness": 1e308, "strength": -1e308}})), + ("effect.apply", json!({"effect": "brushStrokes.darkStrokes", "params": {"balance": -1e308, "blackIntensity": {}, "whiteIntensity": 1e308}})), + ( + "effect.apply", + json!({"effect": "brushStrokes.inkOutlines", "params": {"strokeLength": -1, "darkIntensity": 1e308, "lightIntensity": "x"}}), + ), + ("effect.apply", json!({"effect": "brushStrokes.spatter", "params": {"sprayRadius": 1e308, "smoothness": "NaN"}})), + ( + "effect.apply", + json!({"effect": "brushStrokes.sprayedStrokes", "params": {"strokeLength": 1e308, "sprayRadius": -1e308, "strokeDirection": 3}}), + ), + ("effect.apply", json!({"effect": "brushStrokes.sumiE", "params": {"strokeWidth": 0, "strokePressure": 1e308, "contrast": [1]}})), ("effect.apply", json!({"effect": "pixelate.colorHalftone", "params": {"maxRadius": 1e308, "channel1": "1e999", "channel4": null}})), ("effect.apply", json!({"effect": "pixelate.crystallize", "params": {"cellSize": -1e308}})), ("effect.apply", json!({"effect": "pixelate.mezzotint", "params": {"type": 5}})), diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 1337e3413..e29175ff0 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -165,6 +165,7 @@ fn choices(effect: &str, key: &str) -> Option { ("blur.radial", "method") => Some(&[("Spin", "spin"), ("Zoom", "zoom")]), ("blur.radial", "quality") => Some(&[("Draft", "draft"), ("Good", "good"), ("Best", "best")]), ("blur.smart", "quality") => Some(&[("Low", "low"), ("Medium", "medium"), ("High", "high")]), + ("brushStrokes.sprayedStrokes", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS), ("distort.glass", "texture") => Some(&vectorcraft_effects::pixel::GLASS_TEXTURES), ("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES), ("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f02a85f82..4cafe5776 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -723,3 +723,11 @@ lighting Light Světlo Offset Odsazení Rotation Otočení Cancel Zrušit + Accented Edges… Zdůrazněné okraje… + Angled Strokes… Šikmé tahy… + Crosshatch… Křížové šrafování… + Dark Strokes… Tmavé tahy… + Ink Outlines… Obrysy tuší… + Spatter… Rozstřik… + Sprayed Strokes… Stříkané tahy… + Sumi-e… Sumi-e… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ca51323e1..b64e2878b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4016,3 +4016,34 @@ theme Appearance Erscheinungsbild Shading Schattierung Show axis Achse einblenden Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Entfernt bei „Aussehen umwandeln“ verdeckte Geometrie. Die Live-Vorschau behält die ganze Oberfläche. Transparente Farben und Oberflächen jenseits der Sichtbarkeitsgrenzen behalten alle Flächen. + Accented Edges… Kanten betonen… + Angled Strokes… Schräge Striche… + Crosshatch… Kreuzschraffur… + Dark Strokes… Dunkle Striche… + Ink Outlines… Tintenumrisse… + Spatter… Spritzer… + Sprayed Strokes… Gesprühte Striche… + Sumi-e… Sumi-e… + Accented Edges Kanten betonen + Angled Strokes Schräge Striche + Crosshatch Kreuzschraffur + Dark Strokes Dunkle Striche + Ink Outlines Tintenumrisse + Spatter Spritzer + Sprayed Strokes Gesprühte Striche + Sumi-e Sumi-e + Direction Balance Richtungsbalance + Stroke Length Strichlänge + Sharpness Schärfe + Strength Stärke + Balance Balance + Black Intensity Schwarzintensität + White Intensity Weißintensität + Dark Intensity Dunkle Intensität + Light Intensity Lichtintensität + Spray Radius Sprühradius + Stroke Direction Strichrichtung + Stroke Width Strichbreite + Stroke Pressure Strichdruck + Right Diagonal Diagonal nach rechts + Left Diagonal Diagonal nach links diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 82ed1583f..cf34a1a30 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4062,3 +4062,33 @@ axis Both Ambos lighting Light Luz Keep visible surfaces only when expanding Conservar solo superficies visibles al expandir Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Recorta la geometría oculta al expandir la apariencia. La vista previa conserva toda la superficie. Las pinturas transparentes y las superficies que superan los límites de visibilidad conservan todas las caras. + Accented Edges… Bordes acentuados… + Angled Strokes… Pinceladas en ángulo… + Crosshatch… Sombreado cruzado… + Dark Strokes… Pinceladas oscuras… + Ink Outlines… Contornos de tinta… + Spatter… Salpicadura… + Sprayed Strokes… Pinceladas pulverizadas… + Sumi-e… Sumi-e… + Accented Edges Bordes acentuados + Angled Strokes Pinceladas en ángulo + Crosshatch Sombreado cruzado + Dark Strokes Pinceladas oscuras + Ink Outlines Contornos de tinta + Spatter Salpicadura + Sprayed Strokes Pinceladas pulverizadas + Sumi-e Sumi-e + Direction Balance Equilibrio de dirección + Stroke Length Longitud de pincelada + Sharpness Nitidez + Strength Fuerza + Balance Equilibrio + Black Intensity Intensidad del negro + White Intensity Intensidad del blanco + Dark Intensity Intensidad oscura + Spray Radius Radio de pulverización + Stroke Direction Dirección de pincelada + Stroke Width Anchura de pincelada + Stroke Pressure Presión de pincelada + Right Diagonal Diagonal derecha + Left Diagonal Diagonal izquierda diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6b6e260a6..67993dce7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4072,3 +4072,33 @@ effect Soft Doux Keep visible surfaces only when expanding Conserver uniquement les surfaces visibles lors de la décomposition Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Découpe la géométrie masquée lors de la décomposition de l’aspect. L’aperçu conserve toute la surface. Les peintures transparentes et les surfaces dépassant les limites de visibilité conservent toutes les faces. lighting Light Lumière + Accented Edges… Contours accentués… + Angled Strokes… Traits obliques… + Crosshatch… Hachures croisées… + Dark Strokes… Traits sombres… + Ink Outlines… Contours à l'encre… + Spatter… Éclaboussures… + Sprayed Strokes… Traits pulvérisés… + Sumi-e… Sumi-e… + Accented Edges Contours accentués + Angled Strokes Traits obliques + Crosshatch Hachures croisées + Dark Strokes Traits sombres + Ink Outlines Contours à l'encre + Spatter Éclaboussures + Sprayed Strokes Traits pulvérisés + Sumi-e Sumi-e + Direction Balance Équilibre de direction + Stroke Length Longueur des traits + Sharpness Netteté + Strength Force + Balance Équilibre + Black Intensity Intensité du noir + White Intensity Intensité du blanc + Dark Intensity Intensité sombre + Spray Radius Rayon de pulvérisation + Stroke Direction Direction des traits + Stroke Width Largeur des traits + Stroke Pressure Pression des traits + Right Diagonal Diagonale droite + Left Diagonal Diagonale gauche diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index eccca0d40..094f6e514 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4064,3 +4064,33 @@ effect Regular Normale lighting Light Luce Keep visible surfaces only when expanding Mantieni solo le superfici visibili durante l’espansione Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Ritaglia la geometria coperta durante Espandi aspetto. L’anteprima mantiene la superficie completa. Le tinte trasparenti e le superfici che superano i limiti di visibilità mantengono tutte le facce. + Accented Edges… Bordi accentuati… + Angled Strokes… Pennellate angolate… + Crosshatch… Tratteggio incrociato… + Dark Strokes… Pennellate scure… + Ink Outlines… Contorni a china… + Spatter… Spruzzo… + Sprayed Strokes… Pennellate spruzzate… + Sumi-e… Sumi-e… + Accented Edges Bordi accentuati + Angled Strokes Pennellate angolate + Crosshatch Tratteggio incrociato + Dark Strokes Pennellate scure + Ink Outlines Contorni a china + Spatter Spruzzo + Sprayed Strokes Pennellate spruzzate + Sumi-e Sumi-e + Direction Balance Bilanciamento direzione + Stroke Length Lunghezza pennellata + Sharpness Nitidezza + Strength Forza + Balance Bilanciamento + Black Intensity Intensità nero + White Intensity Intensità bianco + Dark Intensity Intensità scuri + Spray Radius Raggio spruzzo + Stroke Direction Direzione pennellata + Stroke Width Larghezza pennellata + Stroke Pressure Pressione pennellata + Right Diagonal Diagonale destra + Left Diagonal Diagonale sinistra diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 179e4b86d..e109c1ea3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4057,3 +4057,33 @@ effect Vertical 垂直 lighting Light ライト Keep visible surfaces only when expanding アピアランス分割時に見える面だけを保持 Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. アピアランスの分割時に隠れた部分を除去します。プレビューでは面全体を保持します。透明なペイントや可視性処理の制限を超える面は、すべての面を保持します。 + Accented Edges… エッジの強調… + Angled Strokes… 斜めのストローク… + Crosshatch… クロスハッチ… + Dark Strokes… 暗いストローク… + Ink Outlines… インクの輪郭… + Spatter… はね… + Sprayed Strokes… スプレーストローク… + Sumi-e… 墨絵… + Accented Edges エッジの強調 + Angled Strokes 斜めのストローク + Crosshatch クロスハッチ + Dark Strokes 暗いストローク + Ink Outlines インクの輪郭 + Spatter はね + Sprayed Strokes スプレーストローク + Sumi-e 墨絵 + Direction Balance 方向のバランス + Stroke Length ストロークの長さ + Sharpness シャープさ + Strength 強度 + Balance バランス + Black Intensity 黒の強さ + White Intensity 白の強さ + Dark Intensity 暗部の強さ + Spray Radius スプレー半径 + Stroke Direction ストロークの方向 + Stroke Width ストロークの幅 + Stroke Pressure ストロークの筆圧 + Right Diagonal 右斜め + Left Diagonal 左斜め diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ec9b4d38c..c1d67d4bc 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2781,3 +2781,11 @@ lighting Light Luz Left Esquerda Right Direita Offset Deslocamento + Accented Edges… Bordas Acentuadas… + Angled Strokes… Traços Angulares… + Crosshatch… Hachura Cruzada… + Dark Strokes… Traços Escuros… + Ink Outlines… Contornos a Tinta… + Spatter… Respingo… + Sprayed Strokes… Traços Borrifados… + Sumi-e… Sumi-e… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a33169371..6226e177c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4060,3 +4060,33 @@ axis Both Оба lighting Light Свет Keep visible surfaces only when expanding При разборе сохранять только видимые поверхности Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Удаляет скрытую геометрию при разборе оформления. Предпросмотр сохраняет всю поверхность. Прозрачные краски и поверхности, превышающие ограничения видимости, сохраняют все грани. + Accented Edges… Акцент на краях… + Angled Strokes… Наклонные мазки… + Crosshatch… Перекрёстная штриховка… + Dark Strokes… Тёмные мазки… + Ink Outlines… Контуры тушью… + Spatter… Разбрызгивание… + Sprayed Strokes… Распылённые мазки… + Sumi-e… Суми-э… + Accented Edges Акцент на краях + Angled Strokes Наклонные мазки + Crosshatch Перекрёстная штриховка + Dark Strokes Тёмные мазки + Ink Outlines Контуры тушью + Spatter Разбрызгивание + Sprayed Strokes Распылённые мазки + Sumi-e Суми-э + Direction Balance Баланс направления + Stroke Length Длина мазка + Sharpness Резкость + Strength Сила + Balance Баланс + Black Intensity Интенсивность чёрного + White Intensity Интенсивность белого + Dark Intensity Интенсивность тёмного + Spray Radius Радиус распыления + Stroke Direction Направление мазков + Stroke Width Ширина мазка + Stroke Pressure Нажим мазка + Right Diagonal Диагональ вправо + Left Diagonal Диагональ влево diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6de840be1..bc0003687 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4043,3 +4043,33 @@ axis Both Обидві Keep visible surfaces only when expanding Під час розгортання зберігати лише видимі поверхні Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Обрізає приховану геометрію під час розгортання оформлення. Попередній перегляд зберігає всю поверхню. Прозорі заливки та поверхні, що перевищують обмеження видимості, зберігають усі грані. lighting Light Світло + Accented Edges… Акцент на краях… + Angled Strokes… Похилі мазки… + Crosshatch… Перехресне штрихування… + Dark Strokes… Темні мазки… + Ink Outlines… Контури тушшю… + Spatter… Розбризкування… + Sprayed Strokes… Розпилені мазки… + Sumi-e… Сумі-е… + Accented Edges Акцент на краях + Angled Strokes Похилі мазки + Crosshatch Перехресне штрихування + Dark Strokes Темні мазки + Ink Outlines Контури тушшю + Spatter Розбризкування + Sprayed Strokes Розпилені мазки + Sumi-e Сумі-е + Direction Balance Баланс напрямку + Stroke Length Довжина мазка + Sharpness Різкість + Strength Сила + Balance Баланс + Black Intensity Інтенсивність чорного + White Intensity Інтенсивність білого + Dark Intensity Інтенсивність темного + Spray Radius Радіус розпилення + Stroke Direction Напрямок мазків + Stroke Width Ширина мазка + Stroke Pressure Натиск мазка + Right Diagonal Діагональ праворуч + Left Diagonal Діагональ ліворуч diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 59a88ce3e..6358d2ae8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3693,3 +3693,33 @@ effect Vertical 垂直 lighting Light 光源 Keep visible surfaces only when expanding 扩展时仅保留可见表面 Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. 扩展外观时裁剪被遮挡的几何区域。实时预览保留完整表面。透明绘制或超出可见性处理限制的表面保留所有面。 + Accented Edges… 强化的边缘… + Angled Strokes… 成角的线条… + Crosshatch… 阴影线… + Dark Strokes… 深色线条… + Ink Outlines… 墨水轮廓… + Spatter… 喷溅… + Sprayed Strokes… 喷色描边… + Sumi-e… 水墨画… + Accented Edges 强化的边缘 + Angled Strokes 成角的线条 + Crosshatch 阴影线 + Dark Strokes 深色线条 + Ink Outlines 墨水轮廓 + Spatter 喷溅 + Sprayed Strokes 喷色描边 + Sumi-e 水墨画 + Direction Balance 方向平衡 + Stroke Length 描边长度 + Sharpness 锐化程度 + Strength 力度 + Balance 平衡 + Black Intensity 黑色强度 + White Intensity 白色强度 + Dark Intensity 深色强度 + Spray Radius 喷色半径 + Stroke Direction 描边方向 + Stroke Width 描边宽度 + Stroke Pressure 描边压力 + Right Diagonal 右对角线 + Left Diagonal 左对角线 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 77ac676d1..2df10453f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3474,3 +3474,33 @@ effect Soft 柔和 lighting Light 光源 Keep visible surfaces only when expanding 展開時僅保留可見表面 Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. 展開外觀時裁剪被遮擋的幾何區域。即時預覽保留完整表面。透明繪製或超出可見性處理限制的表面保留所有面。 + Accented Edges… 強調邊緣… + Angled Strokes… 傾斜筆觸… + Crosshatch… 交叉影線… + Dark Strokes… 深色筆觸… + Ink Outlines… 墨水輪廓… + Spatter… 噴濺… + Sprayed Strokes… 噴灑筆觸… + Sumi-e… 水墨畫… + Accented Edges 強調邊緣 + Angled Strokes 傾斜筆觸 + Crosshatch 交叉影線 + Dark Strokes 深色筆觸 + Ink Outlines 墨水輪廓 + Spatter 噴濺 + Sprayed Strokes 噴灑筆觸 + Sumi-e 水墨畫 + Direction Balance 方向平衡 + Stroke Length 筆觸長度 + Sharpness 銳利度 + Strength 力度 + Balance 平衡 + Black Intensity 黑色強度 + White Intensity 白色強度 + Dark Intensity 暗部強度 + Spray Radius 噴灑半徑 + Stroke Direction 筆觸方向 + Stroke Width 筆觸寬度 + Stroke Pressure 筆觸壓力 + Right Diagonal 右對角線 + Left Diagonal 左對角線 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4ddfd6287..091fb5da3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3303,8 +3303,9 @@ fn font_items(english_names: bool) -> Vec { /// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the /// vector effects): (submenu, the catalogue's menu path of its effects). -const RASTER_MENUS: [(&str, &[&str]); 6] = [ +const RASTER_MENUS: [(&str, &[&str]); 7] = [ ("Blur", &["Effect", "Blur"]), + ("Brush Strokes", &["Effect", "Brush Strokes"]), ("Distort", &["Effect", "Distort"]), ("Pixelate", &["Effect", "Pixelate"]), ("Sharpen", &["Effect", "Sharpen"]), diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index a5b37af9a..eeade4fb9 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -95,6 +95,24 @@ fn effect_menu_lists_the_distort_filters_between_blur_and_pixelate() { assert!(raster.contains(&"Distort & Transform")); } +/// Effect › Brush Strokes, between Blur and Distort: each filter opens its dialog. +#[test] +fn effect_menu_lists_the_brush_strokes_filters_between_blur_and_distort() { + let app = app(); + let entries = menus::menu_entries(&app); + let strokes: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Brush Strokes"]).collect(); + let labels: Vec<&str> = strokes.iter().map(|e| e.label.as_str()).collect(); + assert_eq!( + labels, + ["Accented Edges…", "Angled Strokes…", "Crosshatch…", "Dark Strokes…", "Ink Outlines…", "Spatter…", "Sprayed Strokes…", "Sumi-e…"] + ); + assert!(strokes.iter().all(|e| e.command.as_deref() == Some("effect.dialog"))); + let raster: Vec<&str> = + entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect(); + let at = |name: &str| raster.iter().position(|s| *s == name).unwrap(); + assert!(at("Blur") < at("Brush Strokes") && at("Brush Strokes") < at("Distort")); +} + /// Effect › Texture, between Sharpen and Video: each filter opens its dialog. #[test] fn effect_menu_lists_the_texture_filters_between_sharpen_and_video() { diff --git a/docs/effects-parity.md b/docs/effects-parity.md index cd2e7a83c..afd2338ce 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -12,7 +12,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Group | Illustrator | VectorCraft | % | Remaining (h) | |---|---:|---:|---:|---:| | Illustrator effects (vector and stylize) | 50 | 44 | 88% | 6–10 | -| Photoshop effects (raster, Effect Gallery) | 57 | 20 | 35% | 30–50 | +| Photoshop effects (raster, Effect Gallery) | 57 | 28 | 49% | 24–40 | | 3D and Materials | 4 (+3 Classic) | 1 (initial Revolve) | ~8% | 45–75 | | Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — | @@ -38,7 +38,7 @@ Document Raster Effects Settings exists. |---|---|---:|---| | Artistic | Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting, Watercolor | 0/15 | all | | Blur | Gaussian Blur, Radial Blur, Smart Blur | 3/3 | Smart Blur's Edge Only and Overlay Edge modes | -| Brush Strokes | Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e | 0/8 | all | +| Brush Strokes | Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e | 8/8 | — | | Distort | Diffuse Glow, Glass, Ocean Ripple | 3/3 | Glass's Load Texture | | Pixelate | Color Halftone, Crystallize, Mezzotint, Pointillize | 4/4 | — | | Sharpen | Unsharp Mask | 1/1 | — | @@ -50,7 +50,7 @@ Document Raster Effects Settings exists. The pixel-filter pipeline (`vectorcraft_effects::pixel`) is in place: each new filter is one function, a catalogue row, a menu entry and translations. Measured throughput: 0.7–1.2 agent hours per filter -(see [calibration](target-app-parity.md#calibration)), so the 37 missing filters are ~26–44 h and the Effect +(see [calibration](target-app-parity.md#calibration)), so the 29 missing filters are ~20–35 h and the Effect Gallery dialog another 4–6 h. These parallelize well across agents. ## 3D and Materials @@ -72,5 +72,6 @@ rendering, Turntable, 3D export) is 45–75 h, still the largest single gap. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Brush Strokes landed (8 filters): 28/57 Photoshop effects | | 2026-10-10 | minor | Initial Revolve landed (#846) | | 2026-10-10 | major | First checklist, counted from the effects catalogue (20/57 Photoshop effects, 44/50 Illustrator effects) | diff --git a/docs/gaps.md b/docs/gaps.md index 405d06da5..9d92e4ba6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -22,7 +22,7 @@ stage ([ROADMAP.md](../ROADMAP.md)). | 5 | [G11 Correctness bugs in core tools](#g11-correctness-bugs-in-core-tools) | 8–15 | yes | [target-app-parity.md](target-app-parity.md) | | 6 | [G4 Advanced type](#g4-advanced-type) | 34–47 | core part | [type-parity.md](type-parity.md) | | 7 | [G12 Preferences and panels that don't act yet](#g12-preferences-and-panels-that-dont-act-yet) | 13–20 | yes | [ui-parity.md](ui-parity.md) | -| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 30–50 | no | [effects-parity.md](effects-parity.md) | +| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 24–40 | no | [effects-parity.md](effects-parity.md) | | 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 15–25 | no | [target-app-parity.md](target-app-parity.md) | | 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 8–16 | macOS pressure | [hardware-parity.md](hardware-parity.md) | | 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 17–28 | no | [target-app-parity.md](target-app-parity.md) | @@ -111,12 +111,12 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G2 Photoshop-style raster effects and the Effect Gallery -- **Missing:** 37 of 57 filters (all of Artistic, Brush Strokes and Sketch), the Effect Gallery dialog, Load +- **Missing:** 29 of 57 filters (all of Artistic and Sketch), the Effect Gallery dialog, Load Texture for Glass and Texturizer, Smart Blur's Edge Only and Overlay Edge. Measured in [effects-parity.md](effects-parity.md). -- **Done:** the pixel-filter pipeline and 20 filters (Blur, Distort, Pixelate, Sharpen, Stylize, Texture, - Video), the last 13 on 2026-10-10. -- **Estimate:** 30–50 h (measured 0.7–1.2 h per filter); parallelizes well. +- **Done:** the pixel-filter pipeline and 28 filters (Blur, Brush Strokes, Distort, Pixelate, Sharpen, Stylize, + Texture, Video), Brush Strokes on 2026-10-11. +- **Estimate:** 24–40 h (measured 0.7–1.2 h per filter); parallelizes well. ## G13 Illustrator 2026 (30.x) additions diff --git a/docs/roadmap.md b/docs/roadmap.md index 749f5b70e..a5f7653a7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -31,7 +31,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V 2. **`.ai` files from real users** (G9): layer structure (#951, #868), guides (#779). 10–20 h. 3. **Launch and stability** (G10): the Windows and Linux start-up reports. 20–35 h. 4. **Core-tool bugs** (G11): Shape Builder regions (#937, #893), Asset Export borders (#983). 8–15 h. -5. **Raster effects** (G2) continue alongside: Artistic, Brush Strokes, Sketch, then the Effect Gallery. 30–50 h. +5. **Raster effects** (G2) continue alongside: Artistic, Sketch, then the Effect Gallery. 24–40 h. ## Next diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a3d1de2a4..4d1d1a534 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -11,7 +11,7 @@ the work list made from it. Deep areas have their own checklists: [UI and intera | Number | Value | Kind | |---|---|---| -| **Feature breadth** (does each Illustrator menu item, tool, effect and format exist?) | **~88%** | measured in part: menu tree 348 of 375 items wired (92.8%), tools 89 of 92 (97%), vector effects 44 of 50 (88%), Photoshop-style effects 20 of 57 (35%), 3D 1 of 4 (initial Revolve); blended by the area weights below | +| **Feature breadth** (does each Illustrator menu item, tool, effect and format exist?) | **~88%** | measured in part: menu tree 348 of 375 items wired (92.8%), tools 89 of 92 (97%), vector effects 44 of 50 (88%), Photoshop-style effects 28 of 57 (49%), 3D 1 of 4 (initial Revolve); blended by the area weights below | | **Feature depth** (each area scored by behaviour, weighted) | **~75%** | estimated, area table below | | **Ready for real work, mainstream illustrator** | **~65%** (60–70%) | estimated, see [Ready for real work](#ready-for-real-work) | | **Ready for real work, full Illustrator** (decides the stage) | **~62%** (57–67%) | estimated: additive weighted sum over the dimensions, see [Ready for real work](#ready-for-real-work) | @@ -74,7 +74,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Strokes, brushes, width profiles | 5 | 88% | Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated | | Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated | -| Raster effects (Effect Gallery) | 4 | 40% | 37 Photoshop-style filters (Artistic, Brush Strokes, Sketch), the Effect Gallery dialog, Load Texture | 30–50 | measured count 20/57 | +| Raster effects (Effect Gallery) | 4 | 48% | 29 Photoshop-style filters (Artistic, Sketch), the Effect Gallery dialog, Load Texture | 24–40 | measured count 28/57 | | 3D and Materials (and Turntable) | 4 | ~8% | initial live Revolve done (flat shading, one light, no caps, approximate visibility for intersecting surfaces); Extrude & Bevel, Inflate, Rotate, materials, Turntable views, 3D export | 50–85 | estimated (Revolve landed after the first measure, #846) | | Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, see [type-parity.md](type-parity.md) | 14–19 | estimated | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi, proportional vertical metrics), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated | @@ -114,7 +114,7 @@ review parallelize less and need humans. | Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates | |---|---:|---:|---:|---| -| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (30–50 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem | +| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (24–40 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem | | **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas | | **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish | From 4c011cf42e985155f1f45f6d82e884f7499eaa1d Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:36:51 +0700 Subject: [PATCH 06/97] fix(paint): preserve new-art defaults when paint edits are rejected --- crates/engine/src/cmd/paint.rs | 24 +++++++++++++----------- crates/engine/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b98c6c828..08f0cb8d3 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -375,15 +375,7 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result { /// remember the paint. fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result { let cmd = if fill { "paint.setFill" } else { "paint.setStroke" }; - // New art gets the paint fitted to itself, not placed where this one is. - if fill { - s.paint.fill = unplaced(&paint); - } else { - s.paint.stroke = unplaced(&paint); - } - if bool_or(p, "focus", true) { - s.fill_active = fill; - } + // Resolve the requested targets before mutating defaults or the active proxy. let item = item_target(s, p, cmd)?.of_kind(s, fill); let selected = item.targets(s, p)?; let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); @@ -423,6 +415,16 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result Result { - std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke); let ids = paint_targets(s, p)?; if !ids.is_empty() { s.edit("Swap Fill and Stroke", |d, _| { @@ -458,11 +459,11 @@ fn swap(s: &mut Session, p: &Value) -> Result { Ok(()) })?; } + std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke); ok() } fn default_paint(s: &mut Session, p: &Value) -> Result { - super::newart::reset(s); let ids = paint_targets(s, p)?; if !ids.is_empty() { s.edit("Default Fill and Stroke", |d, _| { @@ -481,6 +482,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { Ok(()) })?; } + super::newart::reset(s); ok() } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ad04e948f..13da65c13 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -134,6 +134,40 @@ fn fill_and_stroke_commands() { s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap(); } + +#[test] +fn rejected_paint_edits_leave_new_art_defaults_and_focus_unchanged() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + let defaults = s.paint.clone(); + let focused = s.fill_active; + let original_fill = s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint(); + let original_stroke = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_paint(); + + // Both shortcut commands used to change defaults before checking invalid ids. + for (command, params) in [ + ("paint.swap", json!({"ids": "not an array"})), + ("paint.default", json!({"ids": ["not an object id"]})), + ("paint.setFill", json!({"color": "#123456", "ids": [false]})), + ("paint.setStroke", json!({"color": "#123456", "ids": "not an array"})), + ("paint.setFill", json!({"color": "#123456", "item": "invalid"})), + ] { + assert!(s.execute(command, ¶ms).is_err(), "{command} should reject {params}"); + assert_eq!(s.paint, defaults, "{command} changed new-art defaults on failure"); + assert_eq!(s.fill_active, focused, "{command} changed the active proxy on failure"); + let appearance = &s.doc().unwrap().doc.node(id).unwrap().appearance; + assert_eq!(appearance.fill_paint(), original_fill); + assert_eq!(appearance.stroke_paint(), original_stroke); + } + + // Successful operations still update both the selected object and new-art defaults. + s.execute("paint.swap", &json!({})).unwrap(); + assert_eq!(s.paint.fill, defaults.stroke); + assert_eq!(s.paint.stroke, defaults.fill); +} + #[test] fn select_same_fill() { let mut s = session(); From e860b01ed83ae23f6d55be12a65689e3a481de01 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 01:36:54 +0700 Subject: [PATCH 07/97] Review #984: rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 13da65c13..69c4ce756 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -134,7 +134,6 @@ fn fill_and_stroke_commands() { s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap(); } - #[test] fn rejected_paint_edits_leave_new_art_defaults_and_focus_unchanged() { let mut s = session(); From 004d015cfe3827ca9dd91ebeec5574a381d891e2 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:39:32 +0700 Subject: [PATCH 08/97] fix(place): reject malformed rectangle and position parameters --- crates/engine/src/cmd/place/mod.rs | 11 ++++++--- crates/engine/src/tests_place.rs | 37 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 9fee1ce57..33e8dc47f 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -309,9 +309,11 @@ fn transformed(mut node: Node, m: Affine) -> Node { /// `[x, y, width, height]` with a positive size. fn rect_param(p: &Value) -> Result> { let Some(v) = p.get("rect") else { return Ok(None) }; - let n: Vec = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - match n[..] { - [x, y, w, h] if w > 0.0 && h > 0.0 => Ok(Some(Rect::new(x, y, x + w, y + h))), + // Reject invalid components instead of dropping them: filtering would turn a malformed + // five-element box such as [0, "bad", 10, 40, 50] into a different valid rectangle. + let numbers = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect::>>()); + match numbers.as_deref() { + Some([x, y, w, h]) if *w > 0.0 && *h > 0.0 => Ok(Some(Rect::new(*x, *y, x + w, y + h))), _ => Err(bad(PLACE, "rect must be [x, y, width, height] with a positive width and height")), } } @@ -343,6 +345,9 @@ fn place(s: &mut Session, p: &Value) -> Result { let replace = bool_or(p, "replace", false); let template = bool_or(p, "template", false); let at = point_param(p, "at"); + if p.get("at").is_some() && (at.is_none() || p["at"].as_array().is_none_or(|a| a.len() != 2)) { + return Err(bad(PLACE, "at must be [x, y] with two numeric coordinates")); + } let rect = rect_param(p)?; if replace && (template || at.is_some() || rect.is_some()) { return Err(bad(PLACE, "replace keeps the replaced object's place and transform: drop template, at and rect")); diff --git a/crates/engine/src/tests_place.rs b/crates/engine/src/tests_place.rs index aba974e5b..daab2d575 100644 --- a/crates/engine/src/tests_place.rs +++ b/crates/engine/src/tests_place.rs @@ -57,6 +57,43 @@ fn serialize(s: &mut Session, format: &str) -> Vec { vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() } + +#[test] +fn malformed_place_coordinates_are_rejected_instead_of_silently_changed() { + let mut s = session(); + let png = png(4, 2, [20, 40, 60, 255], None); + let original = s.doc().unwrap().doc.layers.clone(); + // Previously filter_map dropped the string from a five-element rectangle and + // treated the remaining four values as a different, valid placement. + for rect in [ + json!([0, "ignored", 10, 40, 50]), + json!([10, 20, 40, null, 50]), + json!([10, 20, 40, 50, 60]), + ] { + let mut params = file("tiny.png", &png); + params["rect"] = rect; + assert!(s.execute("file.place", ¶ms).is_err(), "{params}"); + assert_eq!(s.doc().unwrap().doc.layers, original, "failed Place must not add art"); + } + // An explicitly supplied invalid position formerly behaved as though no + // position were supplied and centred the image on the artboard. + for at in [ + json!(["not-a-number", 20]), + json!([10, null]), + json!([10, 20, 30]), + json!("invalid"), + ] { + let mut params = file("tiny.png", &png); + params["at"] = at; + assert!(s.execute("file.place", ¶ms).is_err(), "{params}"); + assert_eq!(s.doc().unwrap().doc.layers, original); + } + let mut params = file("tiny.png", &png); + params["rect"] = json!([10, 20, 80, 60]); + let placed = s.execute("file.place", ¶ms).unwrap(); + assert_eq!(placed["ids"].as_array().unwrap().len(), 1); +} + #[test] fn a_300_px_png_at_300_ppi_places_as_72_pt_centred_selected_and_undoable() { let mut s = session(); From ac6d72b44a23ee6551478d955373f7775876e8b8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 01:44:11 +0700 Subject: [PATCH 09/97] Review #985: one parser for exact number lists; null `at` is no position - `finite_numbers::` (cmd/mod.rs) reads an array of exactly N finite numbers, nothing dropped or padded; `rect` uses it, and the rect cut, Free Distort and anchor handles reuse it in the PRs stacked on this one. - `at` is parsed once (`at_param`) instead of point_param plus a second shape check that indexed the params; `at: null` means no position, as null does for the other optional params, and gets a test. - rustfmt. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 11 +++++++++++ crates/engine/src/cmd/place/mod.rs | 22 +++++++++++++--------- crates/engine/src/tests_place.rs | 18 ++++++------------ 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 228410de9..a3d142ebd 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -324,6 +324,17 @@ pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result(v: &Value) -> Option<[f64; N]> { + let a = v.as_array().filter(|a| a.len() == N)?; + let mut out = [0.0; N]; + for (o, n) in out.iter_mut().zip(a) { + *o = n.as_f64().filter(|n| n.is_finite())?; + } + Some(out) +} + pub(crate) fn point_param(p: &Value, key: &str) -> Option { let a = p.get(key)?.as_array()?; Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)) diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 33e8dc47f..b881d52b8 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -309,15 +309,22 @@ fn transformed(mut node: Node, m: Affine) -> Node { /// `[x, y, width, height]` with a positive size. fn rect_param(p: &Value) -> Result> { let Some(v) = p.get("rect") else { return Ok(None) }; - // Reject invalid components instead of dropping them: filtering would turn a malformed - // five-element box such as [0, "bad", 10, 40, 50] into a different valid rectangle. - let numbers = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect::>>()); - match numbers.as_deref() { - Some([x, y, w, h]) if *w > 0.0 && *h > 0.0 => Ok(Some(Rect::new(*x, *y, x + w, y + h))), + // All four or nothing: a malformed entry dropped would shift the others ([0, "bad", 10, 40, 50]). + match finite_numbers(v) { + Some([x, y, w, h]) if w > 0.0 && h > 0.0 => Ok(Some(Rect::new(x, y, x + w, y + h))), _ => Err(bad(PLACE, "rect must be [x, y, width, height] with a positive width and height")), } } +/// `at`, the `[x, y]` the art is centred on (absent or null: none); anything else fails rather than +/// placing the art as if no point were given. +fn at_param(p: &Value) -> Result> { + p.get("at") + .filter(|v| !v.is_null()) + .map(|v| finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(PLACE, "at must be [x, y]"))) + .transpose() +} + /// The transform that puts new art with the natural box `natural` where `old` is: an image's scale /// and rotation relative to its 100% size (its resolution's) and its centre are kept (with /// `image_xf`); other objects keep their centre. @@ -344,10 +351,7 @@ fn fit(natural: Rect, r: Rect) -> Affine { fn place(s: &mut Session, p: &Value) -> Result { let replace = bool_or(p, "replace", false); let template = bool_or(p, "template", false); - let at = point_param(p, "at"); - if p.get("at").is_some() && (at.is_none() || p["at"].as_array().is_none_or(|a| a.len() != 2)) { - return Err(bad(PLACE, "at must be [x, y] with two numeric coordinates")); - } + let at = at_param(p)?; let rect = rect_param(p)?; if replace && (template || at.is_some() || rect.is_some()) { return Err(bad(PLACE, "replace keeps the replaced object's place and transform: drop template, at and rect")); diff --git a/crates/engine/src/tests_place.rs b/crates/engine/src/tests_place.rs index daab2d575..0fb2c5d74 100644 --- a/crates/engine/src/tests_place.rs +++ b/crates/engine/src/tests_place.rs @@ -57,7 +57,6 @@ fn serialize(s: &mut Session, format: &str) -> Vec { vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() } - #[test] fn malformed_place_coordinates_are_rejected_instead_of_silently_changed() { let mut s = session(); @@ -65,11 +64,7 @@ fn malformed_place_coordinates_are_rejected_instead_of_silently_changed() { let original = s.doc().unwrap().doc.layers.clone(); // Previously filter_map dropped the string from a five-element rectangle and // treated the remaining four values as a different, valid placement. - for rect in [ - json!([0, "ignored", 10, 40, 50]), - json!([10, 20, 40, null, 50]), - json!([10, 20, 40, 50, 60]), - ] { + for rect in [json!([0, "ignored", 10, 40, 50]), json!([10, 20, 40, null, 50]), json!([10, 20, 40, 50, 60])] { let mut params = file("tiny.png", &png); params["rect"] = rect; assert!(s.execute("file.place", ¶ms).is_err(), "{params}"); @@ -77,12 +72,7 @@ fn malformed_place_coordinates_are_rejected_instead_of_silently_changed() { } // An explicitly supplied invalid position formerly behaved as though no // position were supplied and centred the image on the artboard. - for at in [ - json!(["not-a-number", 20]), - json!([10, null]), - json!([10, 20, 30]), - json!("invalid"), - ] { + for at in [json!(["not-a-number", 20]), json!([10, null]), json!([10, 20, 30]), json!("invalid")] { let mut params = file("tiny.png", &png); params["at"] = at; assert!(s.execute("file.place", ¶ms).is_err(), "{params}"); @@ -92,6 +82,10 @@ fn malformed_place_coordinates_are_rejected_instead_of_silently_changed() { params["rect"] = json!([10, 20, 80, 60]); let placed = s.execute("file.place", ¶ms).unwrap(); assert_eq!(placed["ids"].as_array().unwrap().len(), 1); + // A null position is no position, as for the other optional params. + let mut params = file("tiny.png", &png); + params["at"] = Value::Null; + assert_eq!(s.execute("file.place", ¶ms).unwrap()["ids"].as_array().unwrap().len(), 1); } #[test] From bfbe9e1eb6f14b6a65f3bb97d4df9fd8eaa0ebc2 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:41:14 +0700 Subject: [PATCH 10/97] fix(cut): reject malformed rectangle cut bounds --- crates/engine/src/cmd/cut.rs | 8 +++++--- crates/engine/src/tests_cut.rs | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/cut.rs b/crates/engine/src/cmd/cut.rs index a0dfe6d2e..1aafe2452 100644 --- a/crates/engine/src/cmd/cut.rs +++ b/crates/engine/src/cmd/cut.rs @@ -221,9 +221,11 @@ fn rect_cut(s: &mut Session, p: &Value) -> Result { const C: &str = "path.rectCut"; let rect = match p.get("rect") { Some(v) => { - let a: Vec = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - let [x, y, w, h] = a[..] else { return Err(bad(C, "rect must be [x, y, width, height]")) }; - Rect::new(x, y, x + w, y + h).abs() + // Every component must be present and numeric. Dropping invalid elements would + // silently cut the wrong region when a malformed five-element array is supplied. + let values = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect::>>()); + let Some([x, y, w, h]) = values.as_deref() else { return Err(bad(C, "rect must be [x, y, width, height]")) }; + Rect::new(*x, *y, x + w, y + h).abs() } None => Rect::from_points(point_req(p, "from", C)?, point_req(p, "to", C)?), }; diff --git a/crates/engine/src/tests_cut.rs b/crates/engine/src/tests_cut.rs index 7bda31d8d..575e489b8 100644 --- a/crates/engine/src/tests_cut.rs +++ b/crates/engine/src/tests_cut.rs @@ -118,6 +118,29 @@ fn line_cut_that_misses_changes_nothing() { assert!(s.execute("path.lineCut", &json!({"from": [5, 5]})).is_err()); } + +#[test] +fn rect_cut_rejects_non_numeric_rectangle_elements_without_modifying_art() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + select(&mut s, &[id]); + let original = bounds(&s, id); + let history = undo_depth(&s); + // The old parser dropped the string and used the four remaining values. + for params in [ + json!([0, "ignored", 10, 50, 50]), + json!([0, 10, 50, null, 50]), + json!([0, 10, 50, 50, 5]), + ] { + assert!(s.execute("path.rectCut", &json!({"rect": params})).is_err()); + assert!(near(bounds(&s, id), original), "failed cut must not change geometry"); + assert_eq!(undo_depth(&s), history, "failed cut must not add undo history"); + } + // Proper rectangular cuts still work. + s.execute("path.rectCut", &json!({"rect": [10, 10, 70, 70]})).unwrap(); + assert!(near(bounds(&s, id), Rect::new(10.0, 10.0, 80.0, 80.0))); +} + #[test] fn rect_cut_crops_to_real_geometry() { let mut s = session(); From 6eeabcd74268bd021d33f19a008764d46487552c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 01:45:18 +0700 Subject: [PATCH 11/97] Review #986: the rect through finite_numbers; rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/cut.rs | 8 +++----- crates/engine/src/tests_cut.rs | 7 +------ 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/cut.rs b/crates/engine/src/cmd/cut.rs index 1aafe2452..a7a2e47f1 100644 --- a/crates/engine/src/cmd/cut.rs +++ b/crates/engine/src/cmd/cut.rs @@ -221,11 +221,9 @@ fn rect_cut(s: &mut Session, p: &Value) -> Result { const C: &str = "path.rectCut"; let rect = match p.get("rect") { Some(v) => { - // Every component must be present and numeric. Dropping invalid elements would - // silently cut the wrong region when a malformed five-element array is supplied. - let values = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect::>>()); - let Some([x, y, w, h]) = values.as_deref() else { return Err(bad(C, "rect must be [x, y, width, height]")) }; - Rect::new(*x, *y, x + w, y + h).abs() + // All four or nothing: a malformed entry dropped would cut a different region. + let [x, y, w, h] = finite_numbers(v).ok_or_else(|| bad(C, "rect must be [x, y, width, height]"))?; + Rect::new(x, y, x + w, y + h).abs() } None => Rect::from_points(point_req(p, "from", C)?, point_req(p, "to", C)?), }; diff --git a/crates/engine/src/tests_cut.rs b/crates/engine/src/tests_cut.rs index 575e489b8..a0a0d2a7b 100644 --- a/crates/engine/src/tests_cut.rs +++ b/crates/engine/src/tests_cut.rs @@ -118,7 +118,6 @@ fn line_cut_that_misses_changes_nothing() { assert!(s.execute("path.lineCut", &json!({"from": [5, 5]})).is_err()); } - #[test] fn rect_cut_rejects_non_numeric_rectangle_elements_without_modifying_art() { let mut s = session(); @@ -127,11 +126,7 @@ fn rect_cut_rejects_non_numeric_rectangle_elements_without_modifying_art() { let original = bounds(&s, id); let history = undo_depth(&s); // The old parser dropped the string and used the four remaining values. - for params in [ - json!([0, "ignored", 10, 50, 50]), - json!([0, 10, 50, null, 50]), - json!([0, 10, 50, 50, 5]), - ] { + for params in [json!([0, "ignored", 10, 50, 50]), json!([0, 10, 50, null, 50]), json!([0, 10, 50, 50, 5])] { assert!(s.execute("path.rectCut", &json!({"rect": params})).is_err()); assert!(near(bounds(&s, id), original), "failed cut must not change geometry"); assert_eq!(undo_depth(&s), history, "failed cut must not add undo history"); From 059bd8267653948e8662fee78088375631b814b0 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:41:58 +0700 Subject: [PATCH 12/97] fix(stroke): reject malformed dashes and keep defaults atomic --- crates/engine/src/cmd/stroke.rs | 24 ++++++++++++++++-------- crates/engine/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index d397056ad..b448b2c9a 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -100,7 +100,14 @@ impl StrokeChange { let dash = match p.get("dash") { None => None, Some(Value::Null) => Some(None), - Some(Value::Array(a)) => Some(Some(a.iter().filter_map(Value::as_f64).collect::>()).filter(|d| !d.is_empty())), + Some(Value::Array(a)) => { + // Do not silently omit malformed segments and change the user's dash pattern. + let values: Vec = a + .iter() + .map(|v| v.as_f64().ok_or_else(|| bad(cmd, format!("dash lengths must be numbers, got {v}")))) + .collect::>()?; + Some(Some(values).filter(|d| !d.is_empty())) + } Some(v) => return Err(bad(cmd, format!("dash must be a list of lengths or null, got {v}"))), }; Ok(Self { @@ -167,9 +174,6 @@ fn no_targets(ids: &[vectorcraft_doc::NodeId], p: &Value) -> bool { fn stroke_set(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.set"; let change = StrokeChange::parse(p, C)?; - if let Some(w) = change.weight { - s.paint.stroke_width = w; - } let item = item_target(s, p, C)?.of_kind(s, false); let mut ids = item.targets(s, p)?; let new_art = no_targets(&ids, p); @@ -216,10 +220,6 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { st.profile = pr.clone(); } }; - // With nothing selected the Stroke panel sets up the next object drawn. - if new_art && let Some(st) = s.new_art_stroke_mut() { - set(st); - } edit_items(s, &ids, item, C, "Stroke", false, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind @@ -235,6 +235,14 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; + // Commit the new-art template only after the complete request is validated + // and the selected artwork edit has succeeded. + if let Some(w) = change.weight { + s.paint.stroke_width = w; + } + if new_art && let Some(st) = s.new_art_stroke_mut() { + set(st); + } ok() } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 69c4ce756..b336c4280 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -134,6 +134,32 @@ fn fill_and_stroke_commands() { s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap(); } + +#[test] +fn stroke_rejects_invalid_dash_components_and_preserves_defaults_on_error() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 40.0, 30.0); + s.execute("stroke.set", &json!({"weight": 3, "dash": [6, 3]})).unwrap(); + let previous_weight = s.paint.stroke_width; + let previous_stroke = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned(); + + for params in [ + json!({"weight": 10, "dash": [6, "not a number", 3]}), + json!({"weight": 10, "ids": "not an array"}), + json!({"weight": 10, "item": "invalid"}), + json!({"weight": 10, "arrowAlign": "unknown"}), + ] { + assert!(s.execute("stroke.set", ¶ms).is_err(), "{params} must fail"); + assert_eq!(s.paint.stroke_width, previous_weight, "failed stroke edit changed new-art defaults"); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned(), previous_stroke); + } + + // A valid dash list still updates the selected stroke and new-art default width. + s.execute("stroke.set", &json!({"weight": 7, "dash": [4, 2]})).unwrap(); + assert_eq!(s.paint.stroke_width, 7.0); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().width, 7.0); +} + #[test] fn rejected_paint_edits_leave_new_art_defaults_and_focus_unchanged() { let mut s = session(); From de17a00eb24fa1f77aa88f73452d5cf3b0cfae98 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 01:46:22 +0700 Subject: [PATCH 13/97] Review #987: test the next-object stroke with nothing selected; rustfmt A rejected Stroke panel edit with nothing selected leaves the next object's stroke as it was; an accepted one sets its weight and dashes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/stroke.rs | 6 ++---- crates/engine/src/tests.rs | 11 ++++++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index b448b2c9a..de4051e9a 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -102,10 +102,8 @@ impl StrokeChange { Some(Value::Null) => Some(None), Some(Value::Array(a)) => { // Do not silently omit malformed segments and change the user's dash pattern. - let values: Vec = a - .iter() - .map(|v| v.as_f64().ok_or_else(|| bad(cmd, format!("dash lengths must be numbers, got {v}")))) - .collect::>()?; + let values: Vec = + a.iter().map(|v| v.as_f64().ok_or_else(|| bad(cmd, format!("dash lengths must be numbers, got {v}")))).collect::>()?; Some(Some(values).filter(|d| !d.is_empty())) } Some(v) => return Err(bad(cmd, format!("dash must be a list of lengths or null, got {v}"))), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b336c4280..1a45a94e1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -134,7 +134,6 @@ fn fill_and_stroke_commands() { s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap(); } - #[test] fn stroke_rejects_invalid_dash_components_and_preserves_defaults_on_error() { let mut s = session(); @@ -158,6 +157,16 @@ fn stroke_rejects_invalid_dash_components_and_preserves_defaults_on_error() { s.execute("stroke.set", &json!({"weight": 7, "dash": [4, 2]})).unwrap(); assert_eq!(s.paint.stroke_width, 7.0); assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().width, 7.0); + + // With nothing selected the panel sets up the next object drawn: a rejected edit doesn't. + s.execute("select.none", &json!({})).unwrap(); + let next = s.new_art(); + assert!(s.execute("stroke.set", &json!({"weight": 12, "dash": [2, 2], "arrowAlign": "unknown"})).is_err()); + assert_eq!(s.new_art(), next); + s.execute("stroke.set", &json!({"weight": 12, "dash": [2, 2]})).unwrap(); + let next = s.new_art(); + let stroke = next.stroke().unwrap(); + assert_eq!((stroke.width, stroke.dash.as_ref().map(|d| d.pattern.clone())), (12.0, Some(vec![2.0, 2.0]))); } #[test] From 3b742b6c0c28e9c136b45fb5ec4ac0f07261a582 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:55:54 +0700 Subject: [PATCH 14/97] fix(assets): validate all explicit export object ids before collecting --- crates/engine/src/cmd/assets.rs | 13 +++++++------ crates/engine/src/tests_assets.rs | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index c8a817d56..7962b2db8 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -80,12 +80,13 @@ fn clean_name(name: &str) -> Option { fn add(s: &mut Session, p: &Value) -> Result { const C: &str = "assets.add"; - let ids = match ids_param(p, "ids") { - Some(ids) => { - let d = &s.doc()?.doc; - roots_of(d, d.paint_order(ids)) - } - None => selected_roots(s)?, + let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { + // An explicit list is authoritative: never silently drop malformed or stale ids. + let ids = checked_ids_param(s, p, "ids", C)?; + let d = &s.doc()?.doc; + roots_of(d, d.paint_order(ids)) + } else { + selected_roots(s)? }; if ids.is_empty() { return Err(bad(C, "select the art to collect (or give its ids)")); diff --git a/crates/engine/src/tests_assets.rs b/crates/engine/src/tests_assets.rs index 2a674b156..ec9e9524c 100644 --- a/crates/engine/src/tests_assets.rs +++ b/crates/engine/src/tests_assets.rs @@ -265,3 +265,26 @@ fn assets_export_into_a_folder() { assert!(dir.join("2x/Logo@2x.png").is_file() && dir.join("PDF/Logo.pdf").is_file()); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn collecting_explicit_assets_rejects_invalid_ids_without_adding_anything() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0); + let original_selection = s.doc().unwrap().selection.objects.clone(); + let before = undo_len(&s); + for ids in [ + json!([a, "not an object"]), + json!([a, 3.5]), + json!([a, u64::MAX]), + json!({"id": a}), + ] { + assert!(s.execute("assets.add", &json!({"ids": ids})).is_err(), "invalid ids: {ids}"); + assert!(assets(&mut s).is_empty(), "invalid ids must not collect only the valid subset"); + assert_eq!(undo_len(&s), before); + assert_eq!(s.doc().unwrap().selection.objects, original_selection); + } + // Valid ids still collect art in document paint order. + assert_eq!(run(&mut s, "assets.add", json!({"ids": [b, a]}))["added"], 2); + assert_eq!(assets(&mut s).len(), 2); +} From 2f458842ae020ef0252fa523684e21dcdb69bd7f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 01:46:59 +0700 Subject: [PATCH 15/97] Review #996: rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_assets.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/engine/src/tests_assets.rs b/crates/engine/src/tests_assets.rs index ec9e9524c..79c11c997 100644 --- a/crates/engine/src/tests_assets.rs +++ b/crates/engine/src/tests_assets.rs @@ -273,12 +273,7 @@ fn collecting_explicit_assets_rejects_invalid_ids_without_adding_anything() { let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0); let original_selection = s.doc().unwrap().selection.objects.clone(); let before = undo_len(&s); - for ids in [ - json!([a, "not an object"]), - json!([a, 3.5]), - json!([a, u64::MAX]), - json!({"id": a}), - ] { + for ids in [json!([a, "not an object"]), json!([a, 3.5]), json!([a, u64::MAX]), json!({"id": a})] { assert!(s.execute("assets.add", &json!({"ids": ids})).is_err(), "invalid ids: {ids}"); assert!(assets(&mut s).is_empty(), "invalid ids must not collect only the valid subset"); assert_eq!(undo_len(&s), before); From 2f984f2e7ab77ab55ca055dcbd7558f0596bdbd0 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:58:13 +0700 Subject: [PATCH 16/97] fix(selection): reject malformed anchor references before selection changes --- crates/engine/src/cmd/select.rs | 45 +++++++++++++++++++++++++++------ crates/engine/src/tests.rs | 33 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 8e018e799..6c2a34374 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -301,10 +301,34 @@ pub(crate) fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> { .unwrap_or_default() } +/// Parse an explicitly supplied anchor list without dropping invalid entries. +fn checked_refs(v: Option<&Value>, cmd: &str) -> Result> { + let refs = v.and_then(Value::as_array).ok_or_else(|| bad(cmd, "`anchors` must be an array of [subpath, anchor] pairs"))?; + refs.iter() + .map(|v| { + let Some([subpath, anchor]) = v.as_array().map(Vec::as_slice) else { + return Err(bad(cmd, format!("invalid anchor reference {v}: expected [subpath, anchor]"))); + }; + let coordinate = |v: &Value| { + v.as_u64() + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| bad(cmd, format!("invalid anchor index {v}: expected a non-negative integer"))) + }; + Ok((coordinate(subpath)?, coordinate(anchor)?)) + }) + .collect() +} + fn anchors(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("select.anchors", "missing id"))?; - let refs = parse_refs(p.get("anchors")); - let mode = str_param(p, "mode").unwrap_or("set").to_string(); + const C: &str = "select.anchors"; + let id = checked_id(s, p.get("id").ok_or_else(|| bad(C, "missing id"))?, C)?; + let refs = checked_refs(p.get("anchors"), C)?; + let mode = match str_param(p, "mode") { + None | Some("set") => "set", + Some("add") => "add", + Some("toggle") => "toggle", + Some(m) => return Err(bad(C, format!("unknown mode {m:?}"))), + }; s.select(|_, sel| { if mode == "set" { sel.clear(); @@ -323,11 +347,16 @@ fn anchors(s: &mut Session, p: &Value) -> Result { } fn anchors_many(s: &mut Session, p: &Value) -> Result { - let items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = p - .get("items") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|it| Some((NodeId(it.get("id")?.as_u64()?), parse_refs(it.get("anchors")).into_iter().collect()))).collect()) - .unwrap_or_default(); + const C: &str = "select.anchorsMany"; + let raw = p.get("items").and_then(Value::as_array).ok_or_else(|| bad(C, "`items` must be an array of {id, anchors}"))?; + let items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = raw + .iter() + .map(|it| { + let id = checked_id(s, it.get("id").ok_or_else(|| bad(C, "each item needs an id"))?, C)?; + let refs = checked_refs(it.get("anchors"), C)?; + Ok((id, refs.into_iter().collect())) + }) + .collect::>()?; // `add: true` is the older spelling of mode add. let mode = match str_param(p, "mode") { Some(m @ ("set" | "add" | "toggle" | "subtract")) => m, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1a45a94e1..60deb977b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -960,3 +960,36 @@ fn given_targets_are_used_as_given_and_range_style_takes_the_selected_type() { 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()); } + +#[test] +fn malformed_anchor_selection_never_replaces_or_partially_changes_selection() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 30.0, 20.0); + let b = rect(&mut s, 50.0, 10.0, 30.0, 20.0); + let expected = s.doc().unwrap().selection.objects.clone(); + let expected_anchors = s.doc().unwrap().selection.anchors.clone(); + for p in [ + json!({"id": a.0, "anchors": [[0, 0], ["bad", 2]]}), + json!({"id": a.0, "anchors": [[0, 0, 1]]}), + json!({"id": a.0, "anchors": "not an array"}), + json!({"id": u64::MAX, "anchors": []}), + json!({"id": a.0, "anchors": [], "mode": "missing"}), + ] { + assert!(s.execute("select.anchors", &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().selection.objects, expected); + assert_eq!(s.doc().unwrap().selection.anchors, expected_anchors); + } + for p in [ + json!({"items": [{"id": a.0, "anchors": [[0, 0]]}, {"id": b.0, "anchors": [[0, "bad"]]}]}), + json!({"items": [{"id": a.0, "anchors": [[0, 0]]}, {"id": "bad", "anchors": [[0, 0]]}]}), + json!({"items": [{"id": u64::MAX, "anchors": []}]}), + json!({"items": [{"id": a.0}]}), + ] { + assert!(s.execute("select.anchorsMany", &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().selection.objects, expected); + assert_eq!(s.doc().unwrap().selection.anchors, expected_anchors); + } + s.execute("select.anchorsMany", &json!({"items": [{"id": a.0, "anchors": [[0, 0]]}]})).unwrap(); + assert!(s.doc().unwrap().selection.contains(a)); + assert!(!s.doc().unwrap().selection.contains(b)); +} From 93cadc8994b8aaf0870e00db0f92f5be33ff6b13 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 01:48:17 +0700 Subject: [PATCH 17/97] Review #997: one anchor-reference parser, for Reshape area type too `text.reshapeArea` read its anchors with the lenient `parse_refs`, so a malformed reference among good ones moved only the good ones. It now uses `checked_refs` as well, and `parse_refs` (no other user) goes. Test: a mixed list fails without moving anything. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/select.rs | 12 +++--------- crates/engine/src/cmd/typecmd.rs | 2 +- crates/engine/src/tests_typearea.rs | 3 +++ 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 6c2a34374..319a7b33b 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -294,15 +294,9 @@ fn key(s: &mut Session, p: &Value) -> Result { ok() } -/// `[[subpath, anchor]…]` anchor references (malformed entries are left out). -pub(crate) fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> { - v.and_then(Value::as_array) - .map(|a| a.iter().filter_map(|x| Some((x.get(0)?.as_u64()? as usize, x.get(1)?.as_u64()? as usize))).collect()) - .unwrap_or_default() -} - -/// Parse an explicitly supplied anchor list without dropping invalid entries. -fn checked_refs(v: Option<&Value>, cmd: &str) -> Result> { +/// `[[subpath, anchor]…]` anchor references of `cmd`: a malformed entry fails rather than being +/// left out, which would act on other anchors than the ones given. +pub(crate) fn checked_refs(v: Option<&Value>, cmd: &str) -> Result> { let refs = v.and_then(Value::as_array).ok_or_else(|| bad(cmd, "`anchors` must be an array of [subpath, anchor] pairs"))?; refs.iter() .map(|v| { diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 2395b3f5a..5692b6ad3 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -628,7 +628,7 @@ fn area_options(s: &mut Session, p: &Value) -> Result { fn reshape_area(s: &mut Session, p: &Value) -> Result { const C: &str = "text.reshapeArea"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let mut refs = super::select::parse_refs(p.get("anchors")); + let mut refs = super::select::checked_refs(p.get("anchors"), C)?; refs.sort_unstable(); refs.dedup(); if refs.is_empty() { diff --git a/crates/engine/src/tests_typearea.rs b/crates/engine/src/tests_typearea.rs index 946fcd887..9c59eac5f 100644 --- a/crates/engine/src/tests_typearea.rs +++ b/crates/engine/src/tests_typearea.rs @@ -236,6 +236,9 @@ fn reshape_area_takes_untrusted_params() { json!({"id": id.0, "dx": 1, "dy": 1}), json!({"id": id.0, "anchors": [], "dx": 1, "dy": 1}), json!({"id": id.0, "anchors": [["a", 1], [0], "x"], "dx": 1, "dy": 1}), + // A malformed reference among good ones fails rather than moving only the good ones. + json!({"id": id.0, "anchors": [[0, 2], [0, "3"]], "dx": 1, "dy": 1}), + json!({"id": id.0, "anchors": [[0, 2], [0, 3, 1]], "dx": 1, "dy": 1}), json!({"id": id.0, "anchors": [[0, 9]], "dx": 1, "dy": 1}), json!({"id": id.0, "anchors": [[0, 1], [7, 0]], "dx": 1, "dy": 1}), json!({"id": id.0, "anchors": [[0, 2]], "dy": 1}), From 6bf2a42e22886556a92b0edefcfd2c507b2f1614 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:00:25 +0700 Subject: [PATCH 18/97] fix(path): reject malformed anchors before creating or replacing geometry --- crates/engine/src/cmd/create.rs | 39 +++++++++++++++++++++++++++++++-- crates/engine/src/cmd/path.rs | 21 +++++++++++++----- crates/engine/src/tests.rs | 27 +++++++++++++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index dae94db38..62ea77785 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -396,6 +396,41 @@ pub(crate) fn anchor_from_json(v: &Value) -> Option { Some(a) } +/// Strictly parse command-provided anchors: dropping invalid entries would change geometry. +pub(crate) fn checked_anchor_from_json(v: &Value, cmd: &str) -> Result { + let coord = |key: &str| { + v.get(key) + .and_then(Value::as_f64) + .filter(|n| n.is_finite()) + .ok_or_else(|| bad(cmd, format!("anchor `{key}` must be a finite number"))) + }; + let p = Point::new(coord("x")?, coord("y")?); + let handle = |key: &str| -> Result { + match v.get(key) { + None | Some(Value::Null) => Ok(p), + Some(value) => { + let Some([x, y]) = value.as_array().map(Vec::as_slice) else { + return Err(bad(cmd, format!("anchor `{key}` must be [x, y]"))); + }; + let (Some(x), Some(y)) = (x.as_f64(), y.as_f64()) else { + return Err(bad(cmd, format!("anchor `{key}` must contain two numbers"))); + }; + if !x.is_finite() || !y.is_finite() { + return Err(bad(cmd, format!("anchor `{key}` must contain finite numbers"))); + } + Ok(Point::new(x, y)) + } + } + }; + let mut anchor = Anchor::with_handles(p, handle("in")?, handle("out")?); + match v.get("smooth") { + None | Some(Value::Null) | Some(Value::Bool(false)) => {} + Some(Value::Bool(true)) => anchor.kind = AnchorKind::Smooth, + Some(_) => return Err(bad(cmd, "anchor `smooth` must be a boolean")), + } + Ok(anchor) +} + fn path_create(s: &mut Session, p: &Value) -> Result { let path = if let Some(d) = str_param(p, "d") { let bp = vectorcraft_geom::BezPath::from_svg(d).map_err(|e| bad("path.create", format!("bad path data: {e}")))?; @@ -406,8 +441,8 @@ fn path_create(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_array) .ok_or_else(|| bad("path.create", "missing anchors"))? .iter() - .filter_map(anchor_from_json) - .collect(); + .map(|a| checked_anchor_from_json(a, "path.create")) + .collect::>()?; if anchors.is_empty() { return Err(bad("path.create", "need at least one anchor")); } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index a4f6feafc..763677893 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2}; -use super::create::anchor_from_json; +use super::create::{anchor_from_json, checked_anchor_from_json}; use super::*; use crate::EngineError; @@ -217,12 +217,21 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { let data = PathData::new( subs.iter() .map(|sp| { - SubPath::new( - sp.get("anchors").and_then(Value::as_array).map(|a| a.iter().filter_map(anchor_from_json).collect()).unwrap_or_default(), - bool_or(sp, "closed", false), - ) + let anchors = sp + .get("anchors") + .and_then(Value::as_array) + .ok_or_else(|| bad("path.setAnchors", "each subpath needs an anchors array"))? + .iter() + .map(|a| checked_anchor_from_json(a, "path.setAnchors")) + .collect::>>()?; + let closed = match sp.get("closed") { + None | Some(Value::Null) => false, + Some(Value::Bool(v)) => *v, + Some(_) => return Err(bad("path.setAnchors", "closed must be a boolean")), + }; + Ok(SubPath::new(anchors, closed)) }) - .collect(), + .collect::>>()?, ); s.edit("Reshape", |d, _| { *path_mut(d, id)? = data; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 60deb977b..46cd71d1c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -993,3 +993,30 @@ fn malformed_anchor_selection_never_replaces_or_partially_changes_selection() { assert!(s.doc().unwrap().selection.contains(a)); assert!(!s.doc().unwrap().selection.contains(b)); } + +#[test] +fn path_commands_reject_bad_anchors_before_modifying_geometry() { + let mut s = session(); + let a = rect(&mut s, 10.0, 20.0, 50.0, 30.0); + let original_points: Vec<_> = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect(); + let history = s.doc().unwrap().history.undo.len(); + let valid = json!({"x": 10, "y": 20}); + for invalid in [ + json!({"x": "bad", "y": 40}), + json!({"x": 50}), + json!({"x": 50, "y": 40, "in": [0]}), + json!({"x": 50, "y": 40, "out": [1, "bad"]}), + json!({"x": 50, "y": 40, "smooth": "yes"}), + ] { + let anchors = json!([valid.clone(), invalid]); + assert!(s.execute("path.create", &json!({"anchors": anchors})).is_err(), "{anchors}"); + assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": anchors}]})).is_err(), "{anchors}"); + let points: Vec<_> = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect(); + assert_eq!(points, original_points); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + } + assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": []}, {"closed": true}]})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": [{"x": 0, "y": 0}, {"x": 30, "y": 40}], "closed": false}]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 2); +} From 743fe2521e4b8d44c1e478eac2ca44dfa1109522 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:00:54 +0700 Subject: [PATCH 19/97] fix(path): remove unused legacy anchor parser import --- crates/engine/src/cmd/path.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 763677893..1e87e8b97 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2}; -use super::create::{anchor_from_json, checked_anchor_from_json}; +use super::create::checked_anchor_from_json; use super::*; use crate::EngineError; From 57050b0987be2ee1627c09a7a176794875ea8cfc Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 01:50:30 +0700 Subject: [PATCH 20/97] Review #998: one strict anchor parser; build fix for path.appendAnchor The PR added `checked_anchor_from_json` next to the lenient `anchor_from_json` and dropped path.rs's import of the latter, which `path.appendAnchor` still called, so the engine didn't compile. `anchor_from_json` is now the strict parser itself (handles through `finite_numbers`), used by path.create, path.setAnchors and path.appendAnchor alike: the Pen's appended anchor with a malformed handle fails too instead of losing the handle. Tests: a non-boolean `closed` and a malformed appended handle change nothing. rustfmt. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/create.rs | 52 +++++++++------------------------ crates/engine/src/cmd/path.rs | 6 ++-- crates/engine/src/tests.rs | 3 ++ 3 files changed, 19 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 62ea77785..b4093ec02 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -385,48 +385,22 @@ fn polar_grid(s: &mut Session, p: &Value) -> Result { grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad)) } -pub(crate) fn anchor_from_json(v: &Value) -> Option { - let p = Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?); - let h_in = point_param(v, "in").unwrap_or(p); - let h_out = point_param(v, "out").unwrap_or(p); - let mut a = Anchor::with_handles(p, h_in, h_out); - if v.get("smooth").and_then(Value::as_bool) == Some(true) { - a.kind = AnchorKind::Smooth; - } - Some(a) -} - -/// Strictly parse command-provided anchors: dropping invalid entries would change geometry. -pub(crate) fn checked_anchor_from_json(v: &Value, cmd: &str) -> Result { - let coord = |key: &str| { - v.get(key) - .and_then(Value::as_f64) - .filter(|n| n.is_finite()) - .ok_or_else(|| bad(cmd, format!("anchor `{key}` must be a finite number"))) - }; +/// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when +/// absent or null): anything malformed fails rather than being left out or replaced by a default, +/// which would change the path's shape. +pub(crate) fn anchor_from_json(v: &Value, cmd: &str) -> Result { + let coord = + |key: &str| v.get(key).and_then(Value::as_f64).filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("anchor `{key}` must be a number"))); let p = Point::new(coord("x")?, coord("y")?); - let handle = |key: &str| -> Result { - match v.get(key) { - None | Some(Value::Null) => Ok(p), - Some(value) => { - let Some([x, y]) = value.as_array().map(Vec::as_slice) else { - return Err(bad(cmd, format!("anchor `{key}` must be [x, y]"))); - }; - let (Some(x), Some(y)) = (x.as_f64(), y.as_f64()) else { - return Err(bad(cmd, format!("anchor `{key}` must contain two numbers"))); - }; - if !x.is_finite() || !y.is_finite() { - return Err(bad(cmd, format!("anchor `{key}` must contain finite numbers"))); - } - Ok(Point::new(x, y)) - } - } + let handle = |key: &str| match v.get(key).filter(|h| !h.is_null()) { + None => Ok(p), + Some(h) => finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(cmd, format!("anchor `{key}` must be [x, y]"))), }; let mut anchor = Anchor::with_handles(p, handle("in")?, handle("out")?); - match v.get("smooth") { - None | Some(Value::Null) | Some(Value::Bool(false)) => {} + match v.get("smooth").filter(|b| !b.is_null()) { + None | Some(Value::Bool(false)) => {} Some(Value::Bool(true)) => anchor.kind = AnchorKind::Smooth, - Some(_) => return Err(bad(cmd, "anchor `smooth` must be a boolean")), + Some(_) => return Err(bad(cmd, "anchor `smooth` must be true or false")), } Ok(anchor) } @@ -441,7 +415,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_array) .ok_or_else(|| bad("path.create", "missing anchors"))? .iter() - .map(|a| checked_anchor_from_json(a, "path.create")) + .map(|a| anchor_from_json(a, "path.create")) .collect::>()?; if anchors.is_empty() { return Err(bad("path.create", "need at least one anchor")); diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 1e87e8b97..1c2683cfc 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2}; -use super::create::checked_anchor_from_json; +use super::create::anchor_from_json; use super::*; use crate::EngineError; @@ -129,7 +129,7 @@ fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<&mut PathDa fn append_anchor(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; - let a = anchor_from_json(p).ok_or_else(|| bad("path.appendAnchor", "missing x/y"))?; + let a = anchor_from_json(p, "path.appendAnchor")?; s.edit("Pen", |d, sel| { let path = path_mut(d, id)?; match path.subpaths.last_mut() { @@ -222,7 +222,7 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_array) .ok_or_else(|| bad("path.setAnchors", "each subpath needs an anchors array"))? .iter() - .map(|a| checked_anchor_from_json(a, "path.setAnchors")) + .map(|a| anchor_from_json(a, "path.setAnchors")) .collect::>>()?; let closed = match sp.get("closed") { None | Some(Value::Null) => false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 46cd71d1c..6aee69d72 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1016,6 +1016,9 @@ fn path_commands_reject_bad_anchors_before_modifying_geometry() { assert_eq!(s.doc().unwrap().history.undo.len(), history); } assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": []}, {"closed": true}]})).is_err()); + assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": [valid.clone()], "closed": "yes"}]})).is_err()); + // The Pen's append reads its anchor the same way. + assert!(s.execute("path.appendAnchor", &json!({"id": a.0, "x": 5, "y": 5, "out": [1]})).is_err()); assert_eq!(s.doc().unwrap().history.undo.len(), history); s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": [{"x": 0, "y": 0}, {"x": 30, "y": 40}], "closed": false}]})).unwrap(); assert_eq!(s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 2); From 0d697ed79da3a3e9b9af8ef2deb68f7ba00abb65 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:57:02 +0700 Subject: [PATCH 21/97] fix(distort): validate the full corner quad, source bounds and targets --- crates/engine/src/cmd/xform.rs | 62 ++++++++++++++++++++------------ crates/engine/src/tests_xform.rs | 24 +++++++++++++ 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 6bd63a57d..58586e096 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -132,30 +132,48 @@ fn warp_node(n: &mut Node, pr: &Projective) { } fn distort(s: &mut Session, p: &Value) -> Result { - let corners: Vec = p - .get("corners") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|c| Some(Point::new(c.get(0)?.as_f64()?, c.get(1)?.as_f64()?))).collect()) - .unwrap_or_default(); - if corners.len() != 4 { - return Err(bad("object.distort", "corners must be 4 [x,y] points (TL, TR, BR, BL)")); - } - let ids = match ids_param(p, "ids") { - Some(v) => v, - None => selected_roots(s)?, - }; - let src = match p.get("from").and_then(Value::as_array) { - Some(a) if a.len() == 4 => { - let f: Vec = a.iter().filter_map(Value::as_f64).collect(); - if f.len() != 4 { - return Err(bad("object.distort", "from must be [x0,y0,x1,y1]")); + const C: &str = "object.distort"; + let invalid_corners = || bad(C, "corners must be exactly 4 finite [x,y] points (TL, TR, BR, BL)"); + let points = p.get("corners").and_then(Value::as_array).filter(|a| a.len() == 4).ok_or_else(invalid_corners)?; + let corners: Vec = points + .iter() + .map(|v| { + let Some([x, y]) = v.as_array().map(Vec::as_slice) else { + return Err(invalid_corners()); + }; + let (Some(x), Some(y)) = (x.as_f64(), y.as_f64()) else { + return Err(invalid_corners()); + }; + if !x.is_finite() || !y.is_finite() { + return Err(invalid_corners()); } - Rect::new(f[0], f[1], f[2], f[3]) - } - _ => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("nothing to distort".into()))?, + Ok(Point::new(x, y)) + }) + .collect::>()?; + let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { + checked_ids_param(s, p, "ids", C)? + } else { + selected_roots(s)? }; - if src.width().abs() < 1e-9 || src.height().abs() < 1e-9 { - return Err(EngineError::Other("cannot distort a zero-size bounding box".into())); + if ids.is_empty() { + return Err(bad(C, "nothing to distort")); + } + let src = match p.get("from") { + None | Some(Value::Null) => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?, + Some(v) => { + let Some([x0, y0, x1, y1]) = v.as_array().map(Vec::as_slice) else { + return Err(bad(C, "from must be exactly 4 finite numbers [x0,y0,x1,y1]")); + }; + let coordinate = |v: &Value| { + v.as_f64() + .filter(|n| n.is_finite()) + .ok_or_else(|| bad(C, "from must be exactly 4 finite numbers [x0,y0,x1,y1]")) + }; + Rect::new(coordinate(x0)?, coordinate(y0)?, coordinate(x1)?, coordinate(y1)?) + } + }; + if !src.width().is_finite() || !src.height().is_finite() || src.width().abs() < 1e-9 || src.height().abs() < 1e-9 { + return Err(bad(C, "cannot distort a zero-size or non-finite bounding box")); } let pr = Projective::from_rect(src, [corners[0], corners[1], corners[2], corners[3]]); s.edit("Free Distort", |d, _| { diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests_xform.rs index 0faf7cea6..f5798ffb6 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests_xform.rs @@ -572,3 +572,27 @@ fn copy_from_requires_source() { assert!(s.execute("appearance.copyFrom", &json!({})).is_err()); assert!(s.execute("appearance.copyFrom", &json!({"source": 9999})).is_err()); } + +#[test] +fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let original = bounds(&s, a); + let history = undo_len(&s); + let corners = json!([[10, 0], [90, 0], [100, 100], [0, 100]]); + for p in [ + json!({"corners": [[10, 0, 50], [90, 0], [100, 100], [0, 100]]}), + json!({"corners": [[10, 0], [90, 0], ["bad", 100], [0, 100]]}), + json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100], ["bad"]]}), + json!({"corners": corners, "from": [0, 0, 100]}), + json!({"corners": corners, "from": "not bounds"}), + json!({"corners": corners, "ids": [a.0, "bad"]}), + json!({"corners": corners, "ids": [a.0, u64::MAX]}), + ] { + assert!(s.execute("object.distort", &p).is_err(), "invalid input: {p}"); + assert!(close(bounds(&s, a), original)); + assert_eq!(undo_len(&s), history); + } + s.execute("object.distort", &json!({"corners": corners, "from": [0, 0, 100, 100], "ids": [a.0]})).unwrap(); + assert_eq!(undo_len(&s), history + 1); +} From 2ff5688511489be9439f8bf61b63e6f441d587b7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 01:51:12 +0700 Subject: [PATCH 22/97] Review #999: corners and from through finite_numbers; rustfmt Same checks in a third of the code; the corners destructure into the four points instead of being indexed. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/xform.rs | 47 ++++++++++------------------------ 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 58586e096..8b7d286b5 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -133,49 +133,28 @@ fn warp_node(n: &mut Node, pr: &Projective) { fn distort(s: &mut Session, p: &Value) -> Result { const C: &str = "object.distort"; - let invalid_corners = || bad(C, "corners must be exactly 4 finite [x,y] points (TL, TR, BR, BL)"); - let points = p.get("corners").and_then(Value::as_array).filter(|a| a.len() == 4).ok_or_else(invalid_corners)?; - let corners: Vec = points - .iter() - .map(|v| { - let Some([x, y]) = v.as_array().map(Vec::as_slice) else { - return Err(invalid_corners()); - }; - let (Some(x), Some(y)) = (x.as_f64(), y.as_f64()) else { - return Err(invalid_corners()); - }; - if !x.is_finite() || !y.is_finite() { - return Err(invalid_corners()); - } - Ok(Point::new(x, y)) - }) - .collect::>()?; - let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { - checked_ids_param(s, p, "ids", C)? - } else { - selected_roots(s)? - }; + // All four corners or nothing, each exactly [x, y]: nothing malformed is left out. + let corners: Option> = p + .get("corners") + .and_then(Value::as_array) + .filter(|a| a.len() == 4) + .and_then(|a| a.iter().map(|c| finite_numbers(c).map(|[x, y]| Point::new(x, y))).collect()); + let Some(&[tl, tr, br, bl]) = corners.as_deref() else { return Err(bad(C, "corners must be 4 [x, y] points (TL, TR, BR, BL)")) }; + let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)? } else { selected_roots(s)? }; if ids.is_empty() { return Err(bad(C, "nothing to distort")); } - let src = match p.get("from") { - None | Some(Value::Null) => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?, + let src = match p.get("from").filter(|v| !v.is_null()) { + None => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?, Some(v) => { - let Some([x0, y0, x1, y1]) = v.as_array().map(Vec::as_slice) else { - return Err(bad(C, "from must be exactly 4 finite numbers [x0,y0,x1,y1]")); - }; - let coordinate = |v: &Value| { - v.as_f64() - .filter(|n| n.is_finite()) - .ok_or_else(|| bad(C, "from must be exactly 4 finite numbers [x0,y0,x1,y1]")) - }; - Rect::new(coordinate(x0)?, coordinate(y0)?, coordinate(x1)?, coordinate(y1)?) + let [x0, y0, x1, y1] = finite_numbers(v).ok_or_else(|| bad(C, "from must be [x0, y0, x1, y1]"))?; + Rect::new(x0, y0, x1, y1) } }; if !src.width().is_finite() || !src.height().is_finite() || src.width().abs() < 1e-9 || src.height().abs() < 1e-9 { return Err(bad(C, "cannot distort a zero-size or non-finite bounding box")); } - let pr = Projective::from_rect(src, [corners[0], corners[1], corners[2], corners[3]]); + let pr = Projective::from_rect(src, [tl, tr, br, bl]); s.edit("Free Distort", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { From c845c7f9e0bab4a5ee420a8cab272b4357aa9068 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:43:09 +0700 Subject: [PATCH 23/97] fix(crop): reject malformed crop rectangles without shifting coordinates --- crates/tools/src/cropimage.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/tools/src/cropimage.rs b/crates/tools/src/cropimage.rs index 467182a78..ac022f4f1 100644 --- a/crates/tools/src/cropimage.rs +++ b/crates/tools/src/cropimage.rs @@ -232,9 +232,15 @@ impl Tool for CropImageTool { if key != "rect" { return; } - let n: Vec = value.as_array().map(|v| v.iter().filter_map(Value::as_f64).filter(|n| n.is_finite()).collect()).unwrap_or_default(); - self.rect = match n.as_slice() { - [x, y, w, h] => Some((None, Rect::new(*x, *y, x + w, y + h))), + // Invalid components must discard the whole box, not silently shift the + // coordinates by dropping a non-numeric entry. + let numbers = value + .as_array() + .filter(|a| a.len() == 4) + .and_then(|a| a.iter().map(Value::as_f64).collect::>>()) + .filter(|a| a.iter().all(|n| n.is_finite())); + self.rect = match numbers.as_deref() { + Some([x, y, w, h]) => Some((None, Rect::new(*x, *y, x + w, y + h))), _ => None, }; } @@ -336,6 +342,23 @@ mod tests { assert!(t.overlays(&cx).is_empty() && !t.claims_key(&cx, ToolKey::Enter)); } + + #[test] + fn crop_box_option_requires_exactly_four_finite_numeric_values() { + let mut tool = CropImageTool::default(); + for invalid in [ + json!([10, "ignored", 20, 30, 40]), + json!([10, 20, 30, null, 40]), + json!([10, 20, 30, 40, 50]), + json!("not a rectangle"), + ] { + tool.set_option("rect", &invalid); + assert!(tool.options().get("rect").is_none(), "invalid box should reset crop: {invalid}"); + } + tool.set_option("rect", &json!([10, 20, 30, 40])); + assert_eq!(tool.options()["rect"], json!([10.0, 20.0, 30.0, 40.0])); + } + #[test] fn the_tool_is_made_by_its_id() { assert_eq!(crate::create(ID).id(), ID); From 311d75fc9728ec3c1349bdcf9adc1b40506bb187 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 01:58:43 +0700 Subject: [PATCH 24/97] Review #988: rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/cropimage.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/crates/tools/src/cropimage.rs b/crates/tools/src/cropimage.rs index ac022f4f1..c07116390 100644 --- a/crates/tools/src/cropimage.rs +++ b/crates/tools/src/cropimage.rs @@ -342,16 +342,10 @@ mod tests { assert!(t.overlays(&cx).is_empty() && !t.claims_key(&cx, ToolKey::Enter)); } - #[test] fn crop_box_option_requires_exactly_four_finite_numeric_values() { let mut tool = CropImageTool::default(); - for invalid in [ - json!([10, "ignored", 20, 30, 40]), - json!([10, 20, 30, null, 40]), - json!([10, 20, 30, 40, 50]), - json!("not a rectangle"), - ] { + for invalid in [json!([10, "ignored", 20, 30, 40]), json!([10, 20, 30, null, 40]), json!([10, 20, 30, 40, 50]), json!("not a rectangle")] { tool.set_option("rect", &invalid); assert!(tool.options().get("rect").is_none(), "invalid box should reset crop: {invalid}"); } From 130bacde12cca80ca79e16794a9da8980132c17d Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:51:13 +0700 Subject: [PATCH 25/97] fix(dialogs): preserve integral command parameters in numeric fields (#1000) --- crates/ui-egui/src/dialogs/form.rs | 45 +++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index b1e9a9b75..9902faa8f 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -248,7 +248,7 @@ pub(super) fn param_fields( match v { Value::Number(n) => { if let Some(x) = crate::widgets::plain_field(ui, ("fx-num", &k), n.as_f64().unwrap_or(0.0), "", 3, 140.0) { - d.fields.insert(k, json!(x)); + d.fields.insert(k, numeric_edit_value(&n, x)); changed = true; } } @@ -291,6 +291,22 @@ pub(super) fn param_fields( changed } +/// Keep integer command parameters integral when a numeric field is edited. The editor reads +/// numbers through f64, but a JSON float such as 12.0 is not accepted by Value::as_u64(). +/// Counts (Object Mosaic rows/columns, grid repeats, etc.) would otherwise fall back to their +/// defaults after the user changed them. Fractional input stays fractional for command validation. +fn numeric_edit_value(original: &serde_json::Number, value: f64) -> Value { + if value.is_finite() && value.fract() == 0.0 { + if original.is_u64() && value >= 0.0 && value < u64::MAX as f64 { + return json!(value as u64); + } + if original.is_i64() && value >= i64::MIN as f64 && value < i64::MAX as f64 { + return json!(value as i64); + } + } + json!(value) +} + /// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers /// within their range, whole numbers, checkboxes and dropdowns, in declaration order. Returns /// true when a value changed. The labels (from the parameter keys) and choices are the plug-in's @@ -552,3 +568,30 @@ pub(super) fn caption(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tl!(text)).size(11.0).color(t.text_dim)); } + +#[cfg(test)] +mod numeric_edit_tests { + use super::*; + + #[test] + fn integer_command_parameters_remain_json_integers() { + // Object Mosaic's command parses counts with Value::as_u64(). + let count = json!(10); + let original = count.as_number().unwrap(); + let changed = numeric_edit_value(original, 24.0); + assert_eq!(changed.as_u64(), Some(24)); + assert_eq!(numeric_edit_value(original, 1.0), json!(1)); + // A decimal must not be silently converted to a different integer. + assert_eq!(numeric_edit_value(original, 2.5), json!(2.5)); + } + + #[test] + fn signed_counts_and_float_parameters_retain_their_number_kind() { + let signed = json!(-10); + assert_eq!(numeric_edit_value(signed.as_number().unwrap(), -4.0), json!(-4)); + let float = json!(10.5); + let updated = numeric_edit_value(float.as_number().unwrap(), 12.0); + assert!(updated.is_f64()); + assert_eq!(updated, json!(12.0)); + } +} From 5a9c6e2e063d3d8f2f1deff165c7c51cff8715d5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 02:06:19 +0700 Subject: [PATCH 26/97] Review #1012: whole numbers typed in command dialogs are integers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR kept a field's integer kind only while its value had been an integer: typing 2.5 in Columns and then 3 sent 3.0, and the mosaic fell back to 10 × 10 again. A whole number typed in any command dialog is now an integer (`typed_number`) whatever the field held before; commands that take any number read integers as well, and a fraction stays one. Test: Object › Create Object Mosaic… opened from its menu item, 3 typed over Columns and Enter makes a 3 × 10 mosaic (it made 10 × 10 before, #1000). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/form.rs | 50 +++++++++-------------------- crates/ui-egui/src/dialogs/tests.rs | 32 ++++++++++++++++++ 2 files changed, 47 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 9902faa8f..2b3ace0d0 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -248,7 +248,7 @@ pub(super) fn param_fields( match v { Value::Number(n) => { if let Some(x) = crate::widgets::plain_field(ui, ("fx-num", &k), n.as_f64().unwrap_or(0.0), "", 3, 140.0) { - d.fields.insert(k, numeric_edit_value(&n, x)); + d.fields.insert(k, typed_number(x)); changed = true; } } @@ -291,20 +291,11 @@ pub(super) fn param_fields( changed } -/// Keep integer command parameters integral when a numeric field is edited. The editor reads -/// numbers through f64, but a JSON float such as 12.0 is not accepted by Value::as_u64(). -/// Counts (Object Mosaic rows/columns, grid repeats, etc.) would otherwise fall back to their -/// defaults after the user changed them. Fractional input stays fractional for command validation. -fn numeric_edit_value(original: &serde_json::Number, value: f64) -> Value { - if value.is_finite() && value.fract() == 0.0 { - if original.is_u64() && value >= 0.0 && value < u64::MAX as f64 { - return json!(value as u64); - } - if original.is_i64() && value >= i64::MIN as f64 && value < i64::MAX as f64 { - return json!(value as i64); - } - } - json!(value) +/// A number typed in a command's dialog as JSON: a whole number as an integer, as counts are read +/// (`as_u64`: Object Mosaic's columns and rows, #1000), which `12.0` isn't; a fraction stays one. +/// Commands that take any number read either. +fn typed_number(x: f64) -> Value { + if x.fract() == 0.0 && x.abs() < 1e15 { json!(x as i64) } else { json!(x) } } /// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers @@ -570,28 +561,17 @@ pub(super) fn caption(ui: &mut egui::Ui, text: &str) { } #[cfg(test)] -mod numeric_edit_tests { +mod typed_number_tests { use super::*; #[test] - fn integer_command_parameters_remain_json_integers() { - // Object Mosaic's command parses counts with Value::as_u64(). - let count = json!(10); - let original = count.as_number().unwrap(); - let changed = numeric_edit_value(original, 24.0); - assert_eq!(changed.as_u64(), Some(24)); - assert_eq!(numeric_edit_value(original, 1.0), json!(1)); - // A decimal must not be silently converted to a different integer. - assert_eq!(numeric_edit_value(original, 2.5), json!(2.5)); - } - - #[test] - fn signed_counts_and_float_parameters_retain_their_number_kind() { - let signed = json!(-10); - assert_eq!(numeric_edit_value(signed.as_number().unwrap(), -4.0), json!(-4)); - let float = json!(10.5); - let updated = numeric_edit_value(float.as_number().unwrap(), 12.0); - assert!(updated.is_f64()); - assert_eq!(updated, json!(12.0)); + fn whole_numbers_are_integers_and_fractions_stay_fractions() { + // Whatever the field held before: a count typed after a fraction is a count again. + assert_eq!(typed_number(24.0).as_u64(), Some(24)); + assert_eq!(typed_number(-4.0), json!(-4)); + assert_eq!(typed_number(0.0), json!(0)); + assert_eq!(typed_number(2.5), json!(2.5)); + // Numbers past exact integers stay floats. + assert!(typed_number(1e20).is_f64()); } } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index f9534577b..f0df5c695 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -434,6 +434,38 @@ fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() { assert_eq!(node.appearance.effects.last().map(|e| e.params["scaleH"].clone()), Some(json!(50.0))); } +/// #1000: a count typed in Object › Create Object Mosaic… reaches the command (a whole number typed +/// in a command's dialog is an integer), so the mosaic has that many columns, not the 10 × 10 grid. +#[test] +fn object_mosaic_makes_the_grid_typed_in_its_dialog() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap(); + app.run("object.rasterize", json!({"ppi": 72})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + }; + let entry = crate::menus::menu_entries(&app).into_iter().find(|e| e.label == "Create Object Mosaic…").expect("menu item"); + let (cmd, params) = crate::menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params); + crate::menus::invoke(&mut app, &cmd, params); + // Its first field, Columns, is focused: type 3 over it and press Enter. + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("3".into())]); + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + frame(&mut app, vec![enter]); + assert!(app.ui.dialog.is_none()); + let mut tiles = None; + app.session.doc().unwrap().doc.walk(|n| { + if n.name.as_deref() == Some("Object Mosaic") { + tiles = n.children().map(|c| c.len()); + } + }); + assert_eq!(tiles, Some(3 * 10)); +} + /// #793: Artboard Options lists its fields as Name, Width, Height, not by key. #[test] fn artboard_options_lists_name_then_width_and_height() { From 057c2ee2c1b1ca54528cd0777930ed52b6588793 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:51:40 +0700 Subject: [PATCH 27/97] fix(widgets): honor Shift on numeric spinner buttons (#991) --- crates/ui-egui/src/widgets.rs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 6cb9da82a..d8883a179 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1170,8 +1170,8 @@ fn spin_generic( && let Some(p) = sresp.interact_pointer_pos() && let Some(v) = value { - let nv = if up.contains(p) { v + step } else { v - step }; - out = Some(SpinPick::Value(nv.max(min))); + let modifiers = ui.input(|i| i.modifiers); + out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers))); } let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; if let Some(v) = field(ui, fw) { @@ -1198,6 +1198,14 @@ fn spin_generic( out } +/// Spinner buttons follow the same modifier sizes as ↑/↓ in a focused number field: +/// Shift ×10, Ctrl/Cmd ÷10, otherwise the caller's normal step. +fn spinner_step(value: f64, step: f64, min: f64, up: bool, modifiers: egui::Modifiers) -> f64 { + let multiplier = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)).map_or(1.0, |(_, factor)| *factor); + let delta = step * multiplier; + (value + if up { delta } else { -delta }).max(min) +} + /// Drag phase of a live slider/handle: previews while dragging, commits on release. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Live { @@ -1826,3 +1834,21 @@ pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, val }); out } + +#[cfg(test)] +mod spinner_modifier_tests { + use super::*; + + #[test] + fn shift_steps_stroke_weight_by_ten_points() { + assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::SHIFT), 24.0); + assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::SHIFT), 4.0); + } + + #[test] + fn unmodified_and_command_steps_keep_existing_behavior() { + assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::NONE), 15.0); + assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::COMMAND), 13.9); + assert_eq!(spinner_step(0.0, 1.0, 0.0, false, egui::Modifiers::SHIFT), 0.0); + } +} From 6d5fdc8a2aaf5fb387643a6bdba77b9b60bd3799 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:06:41 +0700 Subject: [PATCH 28/97] test(ui): Shift-click on the Stroke weight stepper steps 10 pt (#991) The headless panel test of #1015, kept on top of #1013's fix (#1015's own change to the spinner is superseded by it). --- crates/ui-egui/src/panels/tests_strokeux.rs | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index 9f6302636..8b0e5f0b9 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -139,3 +139,46 @@ fn weight_presets_display_cleanly_in_their_native_unit() { // Units no stroke is measured in keep the pt ladder, in points. assert_eq!(stroke::weight_presets(Unit::Meters), stroke::weight_presets(Unit::Points)); } + +/// #991: the spinner to the left of Stroke Weight honors Shift on both arrows, while a +/// normal click still steps by one point (the focused numeric field already handles Shift+↑/↓). +#[test] +fn stroke_weight_stepper_uses_ten_point_shift_steps() { + use super::tests_appearance::frame_raw; + + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = app_with_rect(); + run(&mut app, "stroke.set", json!({"weight": 14})); + let t = texts(&ctx, &mut app, stroke::show); + let r = text_rect(&t, "Weight:"); + let x = r.right() + ctx.global_style().spacing.item_spacing.x + 8.0; + let click_step = |app: &mut VectorcraftApp, y: f32, modifiers: egui::Modifiers| { + let pos = egui::pos2(x, y); + let button = |pressed| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers, + }; + for event in [egui::Event::PointerMoved(pos), button(true), button(false)] { + frame_raw( + &ctx, + app, + egui::RawInput { + modifiers, + events: vec![event], + ..Default::default() + }, + stroke::show, + ); + } + }; + + click_step(&mut app, r.center().y - 5.0, egui::Modifiers::SHIFT); + assert_eq!(app.session.shown_stroke().unwrap().width, 24.0); + click_step(&mut app, r.center().y + 5.0, egui::Modifiers::NONE); + assert_eq!(app.session.shown_stroke().unwrap().width, 23.0); + click_step(&mut app, r.center().y + 5.0, egui::Modifiers::SHIFT); + assert_eq!(app.session.shown_stroke().unwrap().width, 13.0); +} From 9e09d53fd96c53a5d2e6313a927ece8e2a23ad69 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 02:06:53 +0700 Subject: [PATCH 29/97] Review #1013: the stepper test from #1015 compiles and hits the spinner The headless test taken from #1015 set `RawInput::modifiers`, which egui 0.36 doesn't have, and clicked left of the spinner (the right-aligned label's text ends before its box). Modifiers now come as `ModifiersChanged` events, as the Appearance panel tests send them, and the click is placed from the field's text. It also checks Ctrl/Cmd steps a tenth. It fails with the spinner ignoring modifiers (15 pt for 24). rustfmt. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/tests_strokeux.rs | 31 ++++++++------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index 8b0e5f0b9..b10b80da9 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -140,8 +140,8 @@ fn weight_presets_display_cleanly_in_their_native_unit() { assert_eq!(stroke::weight_presets(Unit::Meters), stroke::weight_presets(Unit::Points)); } -/// #991: the spinner to the left of Stroke Weight honors Shift on both arrows, while a -/// normal click still steps by one point (the focused numeric field already handles Shift+↑/↓). +/// #991: the spinner left of the Stroke Weight field steps ten with Shift on both arrows and a +/// tenth with Ctrl/Cmd, as ↑/↓ do in the focused field, while a plain click steps one point. #[test] fn stroke_weight_stepper_uses_ten_point_shift_steps() { use super::tests_appearance::frame_raw; @@ -151,27 +151,15 @@ fn stroke_weight_stepper_uses_ten_point_shift_steps() { let mut app = app_with_rect(); run(&mut app, "stroke.set", json!({"weight": 14})); let t = texts(&ctx, &mut app, stroke::show); - let r = text_rect(&t, "Weight:"); - let x = r.right() + ctx.global_style().spacing.item_spacing.x + 8.0; + // The 16 px spinner sits just left of the field's text. + let r = text_rect(&t, "14 pt"); + let x = r.left() - 14.0; let click_step = |app: &mut VectorcraftApp, y: f32, modifiers: egui::Modifiers| { let pos = egui::pos2(x, y); - let button = |pressed| egui::Event::PointerButton { - pos, - button: egui::PointerButton::Primary, - pressed, - modifiers, - }; + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; for event in [egui::Event::PointerMoved(pos), button(true), button(false)] { - frame_raw( - &ctx, - app, - egui::RawInput { - modifiers, - events: vec![event], - ..Default::default() - }, - stroke::show, - ); + let events = vec![egui::Event::ModifiersChanged(modifiers), event]; + frame_raw(&ctx, app, egui::RawInput { events, ..Default::default() }, stroke::show); } }; @@ -181,4 +169,7 @@ fn stroke_weight_stepper_uses_ten_point_shift_steps() { assert_eq!(app.session.shown_stroke().unwrap().width, 23.0); click_step(&mut app, r.center().y + 5.0, egui::Modifiers::SHIFT); assert_eq!(app.session.shown_stroke().unwrap().width, 13.0); + // Ctrl/Cmd steps a tenth, as ↑/↓ and the wheel do in the field. + click_step(&mut app, r.center().y + 5.0, egui::Modifiers::COMMAND); + assert!((app.session.shown_stroke().unwrap().width - 12.9).abs() < 1e-6); } From e0d494b2eb09341e99766beb40daadbba8898626 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 02:24:31 +0700 Subject: [PATCH 30/97] ROADMAP: the validation batch, Object Mosaic counts and stepper modifiers Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index b97cf0a22..eaca3a386 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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) | +| 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). | | 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 | From 178f99d60ef1f5617075dc8e76031ecc90ebb0b2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 01:46:42 +0700 Subject: [PATCH 31/97] #983: supersample Art Optimized edges in exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asset Export, File › Export, Save for Web and Object › Rasterize drew each shape with its own smoothed edge, so where two shapes met edge to edge (Divide, Shape Builder, abutting art) both half-covered the pixels on the line and the background showed through as a thin ghost border. With Art Optimized (the default) these now supersample, as the option's name in Illustrator says: the art is drawn with hard edges at four times the size (three or two times for large images, within a 40-megapixel budget and the renderer's 65,535-pixel side) and each block averaged into one pixel. Pixels shared by two shapes are fully owned by one or the other at the finer size, so no seam is left, and the outer edges are still smooth. The canvas keeps shape-by-shape smoothing for speed; None and Type Optimized are unchanged. RenderOptions::supersample turns it on (exports and Rasterize set it). A render test checks two triangles sharing a diagonal: a seam with shape-by-shape smoothing, none supersampled, and the factor's limits. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/rasterfx.rs | 3 +- crates/engine/src/cmd/webexport.rs | 10 ++++-- crates/render/src/encode/mod.rs | 2 ++ crates/render/src/lib.rs | 53 ++++++++++++++++++++++++++++++ crates/render/src/tests.rs | 38 +++++++++++++++++++++ docs/gaps.md | 3 +- 7 files changed, 105 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index eaca3a386..166186beb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 77b97e31c..f2cc7bccf 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -152,7 +152,8 @@ fn render(doc: &Document, nodes: Vec, region: Rect, scale: f64, anti_alias tmp.layers = vec![Arc::new(layer)]; let mut r = vectorcraft_render::Renderer::new(); r.threads = 0; - r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, ..Default::default() }) + // Art Optimized is supersampled, as in exports: shapes meeting edge to edge leave no seam. + r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, supersample: true, ..Default::default() }) } /// Pixels per point of raster effects rendered as images (the document's raster effects diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs index e809440f2..0ca8294f3 100644 --- a/crates/engine/src/cmd/webexport.rs +++ b/crates/engine/src/cmd/webexport.rs @@ -582,8 +582,14 @@ pub fn render(doc: &Document, s: &WebSettings) -> std::result::Result Option { + (2..=4u32).rev().find(|k| { + let (sw, sh) = (u64::from(w) * u64::from(*k), u64::from(h) * u64::from(*k)); + sw <= u64::from(u16::MAX) && sh <= u64::from(u16::MAX) && sw * sh <= MAX_SUPERSAMPLED_PIXELS + }) +} + +/// Average each `k`×`k` block of `src` (`w`×`h` pixels of 4 premultiplied bytes) into one pixel. +fn downsample(src: &[u8], w: usize, h: usize, k: usize) -> Vec { + let (ow, oh) = (w / k.max(1), h / k.max(1)); + let n = (k * k).max(1) as u32; + let mut out = vec![0u8; ow * oh * 4]; + for (o, px) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let (ox, oy) = (o % ow.max(1), o / ow.max(1)); + let mut acc = [0u32; 4]; + for y in oy * k..(oy + 1) * k { + let row = y * w + ox * k; + for p in src.get(row * 4..(row + k) * 4).unwrap_or_default().as_chunks::<4>().0 { + for (a, v) in acc.iter_mut().zip(p) { + *a += u32::from(*v); + } + } + } + for (d, a) in px.iter_mut().zip(acc) { + *d = ((a + n / 2) / n).min(255) as u8; + } + } + out +} + /// A rendered image (premultiplied RGBA8, row-major). pub struct Rendered { pub width: u32, @@ -476,6 +519,16 @@ impl Renderer { /// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered { let start = now(); + if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() { + let (w, h) = (width.clamp(1, u16::MAX as u32), height.clamp(1, u16::MAX as u32)); + if let Some(k) = supersample_factor(w, h) { + let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() }; + let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks); + let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize); + self.stats.micros = now().saturating_sub(start); + return Rendered { width: w, height: h, pixels }; + } + } let prepared = proof::prepare(doc, opts); let doc: &Document = &prepared; let w = width.clamp(1, u16::MAX as u32) as u16; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b9c4692b6..b94ce1f3b 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -385,3 +385,41 @@ fn images_take_their_nearest_pixel_without_smoothing() { assert!(greys(true) >= 4, "smooth: a ramp from black to white"); assert_eq!(greys(false), 0, "nearest: black, then white"); } + +/// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no +/// seam of the white background between them, where shape-by-shape smoothing shows one; the outer +/// edges stay smooth. +#[test] +fn supersampled_art_leaves_no_seam_between_shapes_that_meet() { + let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + // Two triangles sharing the diagonal of the square (10, 10)–(90, 90). + for pts in [[(10.0, 10.0), (90.0, 10.0), (90.0, 90.0)], [(10.0, 10.0), (90.0, 90.0), (10.0, 90.0)]] { + let mut b = vectorcraft_geom::BezPath::new(); + b.move_to(pts[0]); + b.line_to(pts[1]); + b.line_to(pts[2]); + b.close_path(); + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), Appearance::basic(red(), Paint::None, 0.0))).unwrap(); + } + let draw = |supersample: bool| { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), precise: true, supersample, ..Default::default() }; + Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts) + }; + // The palest pixel on the shared diagonal, away from the corners. + let palest = |r: &Rendered| (20..80).map(|i| r.pixel(i, i)[1].max(r.pixel(i + 1, i)[1])).max().unwrap(); + assert!(palest(&draw(false)) > 20, "shape-by-shape smoothing shows the seam"); + assert_eq!(palest(&draw(true)), 0, "supersampled: no seam"); + // The outer edge is still smoothed: a pixel half covered is between white and red. + let ss = draw(true); + let edge = ss.pixel(50, 9); + assert!(edge == [255, 255, 255, 255] || (edge[1] > 0 && edge[1] < 255) || ss.pixel(50, 10)[1] < 255, "{edge:?}"); + // The factor fits the size and the renderer's limits. + assert_eq!(supersample_factor(100, 100), Some(4)); + assert_eq!(supersample_factor(2000, 2000), Some(3)); + assert_eq!(supersample_factor(3000, 3000), Some(2)); + assert_eq!(supersample_factor(5000, 5000), None); + assert_eq!(supersample_factor(40_000, 10), None); +} diff --git a/docs/gaps.md b/docs/gaps.md index 9d92e4ba6..07b16be3e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -86,8 +86,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G11 Correctness bugs in core tools -- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Asset Export - anti-aliasing ghost borders (#983); Transform effect dialog's invisible checkboxes and missing options (#885); +- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Transform effect dialog's invisible checkboxes and missing options (#885); PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834). - **Impact:** wrong output in everyday work. - **Estimate:** 8–15 h. From e77d15c70d7927477f325e01d08accb63b00809e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:30 +0700 Subject: [PATCH 32/97] #1001: named text styles take leading, kerning, mojikumi and other attributes left out at their defaults Character and paragraph styles checked a new attribute against the keys its base style serializes, and the JSON leaves out Auto leading and kerning, no mojikumi and other defaults. So charStyle.new / setAttrs refused leading or kerning, paraStyle.new refused mojikumi, and a style captured from text with explicit leading failed to apply to Auto-leading text, to duplicate or to come back out of a library. The check now also accepts every field name the style type deserializes (read from serde's derive), still refusing unknown keys and the file-only justify_auto. The test creates and edits styles with kerning, leading, small caps, mojikumi and direction, applies a captured leading style to fresh text with and without Clear Overrides, duplicates it, uses it from a library, and checks `nope` and `justify_auto` are still refused. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- crates/engine/src/cmd/textstyles.rs | 70 ++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 166186beb..2ddc66cfc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 549e95fdf..a46e98591 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -133,14 +133,44 @@ fn to_attrs(v: &T) -> Map { m } +/// Every attribute name `T` reads, including those its JSON leaves out at their defaults (Auto +/// leading and kerning, no mojikumi…): the field names serde's derive hands `deserialize_struct`. +/// Not `justify_auto`, which [`to_map`] turns into `"justify": "Auto"`. +fn attr_names() -> Vec<&'static str> { + use serde::de::{Error as _, Visitor, value::Error}; + + struct Names<'a>(&'a mut &'static [&'static str]); + impl<'de> serde::Deserializer<'de> for Names<'_> { + type Error = Error; + fn deserialize_any>(self, _: V) -> std::result::Result { + Err(Error::custom("not a struct")) + } + fn deserialize_struct>(self, _: &'static str, fields: &'static [&'static str], _: V) -> std::result::Result { + *self.0 = fields; + Err(Error::custom("field names only")) + } + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf option unit + unit_struct newtype_struct seq tuple tuple_struct map enum identifier ignored_any + } + } + + let mut fields: &'static [&'static str] = &[]; + // Always an error: the probe only records the names. + let _ = T::deserialize(Names(&mut fields)); + fields.iter().copied().filter(|&f| f != "justify_auto").collect() +} + /// `base` with `attrs` written over it (unknown or ill-typed attributes are an error). fn with_attrs(base: &T, attrs: &Map, cmd: &str) -> Result { let mut m = to_attrs(base); if let Some(full) = to_map(base) { m.insert("style_name".into(), full.get("style_name").cloned().unwrap_or(Value::Null)); } + let names = attr_names::(); for (k, v) in attrs { - if !m.contains_key(k) { + // Not only the keys `base` writes: those at their defaults (Auto leading…) are left out. + if !m.contains_key(k) && !names.contains(&k.as_str()) { return Err(bad(cmd, format!("unknown attribute `{k}`"))); } m.insert(k.clone(), v.clone()); @@ -575,6 +605,44 @@ mod tests { assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill); } + /// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no + /// mojikumi) are still attributes a style can set, and a style captured with one applies to + /// text at the default, is duplicated and comes back out of a library. + #[test] + fn styles_take_attributes_left_out_at_their_defaults() { + let (mut s, id) = session_with_text("A"); + s.execute("charStyle.new", &json!({"name": "Kerned", "attrs": {"kerning": 40, "leading": 30}})).unwrap(); + s.execute("charStyle.setAttrs", &json!({"name": "Kerned", "attrs": {"kerning": 20, "small_caps": 70}})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Punctuation", "attrs": {"mojikumi": "lineEndHalf"}})).unwrap(); + s.execute("paraStyle.setAttrs", &json!({"name": "Punctuation", "attrs": {"direction": "rightToLeft"}})).unwrap(); + for bad in [json!({"nope": 1}), json!({"justify_auto": true})] { + assert!(s.execute("paraStyle.new", &json!({"name": "Bad", "attrs": bad})).is_err(), "{bad}"); + } + // Captured from text with explicit leading, applied to fresh Auto-leading text. + s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Spaced"})).unwrap(); + let fresh = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(runs(&s, fresh)[0].style.leading, None); + for clear in [false, true] { + s.execute("charStyle.apply", &json!({"name": "Spaced", "id": fresh, "clearOverrides": clear})).unwrap(); + assert_eq!(runs(&s, fresh)[0].style.leading, Some(30.0)); + } + let copy = s.execute("charStyle.duplicate", &json!({"name": "Spaced"})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == copy).unwrap().attrs["leading"], 30.0); + s.execute("charStyle.apply", &json!({"name": "Kerned", "id": fresh})).unwrap(); + let st = runs(&s, fresh)[0].style.clone(); + assert_eq!((st.kerning, st.small_caps), (Some(20.0), Some(70.0))); + s.execute("paraStyle.apply", &json!({"name": "Punctuation", "id": id})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() }; + assert_eq!((t.para.mojikumi, t.para.direction), (vectorcraft_doc::Mojikumi::LineEndHalf, Some(vectorcraft_doc::ParaDirection::RightToLeft))); + // A library keeps the captured attributes and makes the style again where it's used. + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let lib = s.execute("library.add", &json!({"kind": "charStyle", "name": "Lib Spaced"})).unwrap(); + let other = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "C"})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.use", &json!({"library": lib["library"], "kind": "charStyle", "item": lib["name"]})).unwrap(); + assert_eq!(runs(&s, other)[0].style.leading, Some(30.0)); + } + #[test] fn paragraph_styles_and_survive_save() { let (mut s, id) = session_with_text("Body"); From eeb213e5a84c9c731c30bbe0bf708929dee4a142 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:31 +0700 Subject: [PATCH 33/97] #1002: new art is refused when no layer is shown and unlocked With every layer hidden or locked, the insertion layer fell back to the top layer anyway, so drawing a shape, typing, pasting, making a graph, a flare, a grid, trim marks or a symbol put the art on a hidden or locked layer: invisible, or unselectable by Select All. Illustrator refuses to draw on a locked or hidden target layer (public forum answers, e.g. https://community.adobe.com/t5/illustrator/can-t-draw-a-new-object-in-my-document/m-p/11107182). DocState::target_parent is insertion_parent for new art, failing with "the target layer is locked or hidden" when it would be a layer that isn't editable; those creation paths use it. Another layer that is shown and unlocked still takes the art, as before. The message is in the de, es, fr, it, ja, ru and uk catalogs. File > Place is left as it is (another branch reworks it). The test hides, then locks, the only layer: an ellipse, a polar grid, text and a paste each fail with the message and leave the document unchanged; a new layer then takes a rectangle. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- crates/engine/src/cmd/brushsym.rs | 5 +++-- crates/engine/src/cmd/create.rs | 6 +++--- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/graph.rs | 2 +- crates/engine/src/cmd/menucmds.rs | 2 +- crates/engine/src/lib.rs | 8 ++++++++ crates/engine/src/tests_layers.rs | 30 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + 15 files changed, 55 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2ddc66cfc..0e054ab53 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 429dd3146..59dfef535 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -603,7 +603,7 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result { let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); let c = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y)); let size = natural_size(d, &name); - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let id = s.edit("Place Symbol Instance", |d, sel| { let id = d.alloc_id(); d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(c, size) }))?; @@ -843,7 +843,8 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result { let alt = bool_or(p, "alt", false); let size = natural_size(&st.doc, &name); let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set)); - let parent = st.insertion_parent(); + // Only a new Symbol Set needs a layer that takes new art. + let parent = if alt || set.is_some() { st.insertion_parent() } else { st.target_parent()? }; if alt { // Remove instances of this symbol under the brush (within the set, or anywhere). let d = &st.doc; diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index b4093ec02..5b4dd8c14 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -79,7 +79,7 @@ pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance: /// Insert a new object looking like `look` (drawing modes apply); select it. pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option) -> Result { - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let mode = s.draw_mode; let inside = s.draw_inside; let behind_of = s.doc()?.selection.in_paint_order(&s.doc()?.doc).first().copied(); @@ -298,7 +298,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { }; parts.push(("Ring", circle(at, rr), radial(at, rr, stops), 1.0)); } - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let id = s.edit("Flare", |d, sel| { let children = parts .into_iter() @@ -352,7 +352,7 @@ fn arc(s: &mut Session, p: &Value) -> Result { } fn grid_group(s: &mut Session, label: &str, paths: Vec) -> Result { - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width); let id = s.edit(label, |d, sel| { let children = paths diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fb87ce303..d5e1b18ab 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -346,7 +346,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho let off = s.prefs.paste_offset; let st = s.doc()?; let same_doc = clip.source_doc == Some(st.uid); - let parent = st.insertion_parent(); + let parent = st.target_parent()?; // A copied artboard comes back with its art: right of the last artboard, or where it was // (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art. let board = clip diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 2077c8e50..fd2153fde 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -831,7 +831,7 @@ fn create(s: &mut Session, p: &Value) -> Result { spec.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()]; spec.rows = vec![vec![3.0, 2.0], vec![5.0, 4.0], vec![4.0, 6.0], vec![7.0, 5.0]]; } - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let id = s.edit("Graph", |d, sel| { let gid = d.alloc_id(); let mut g = Node::group(gid, vec![]); diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 3d84bd58b..b6385bed0 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -702,7 +702,7 @@ fn trim_marks(s: &mut Session, p: &Value) -> Result { if rects.is_empty() { return Err(bad(C, "nothing to mark")); } - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let ids = s.edit("Create Trim Marks", |d, sel| { let mut ids = vec![]; for r in &rects { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a0463b719..5c54128c3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -346,6 +346,14 @@ impl DocState { // A sublayer takes new art only while it and the layers around it are shown and unlocked. self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer()) } + /// [`Self::insertion_parent`] for new art, which a locked or hidden layer never takes: an error + /// when no layer is shown and unlocked, so the art is refused rather than hidden or locked away. + pub fn target_parent(&self) -> Result> { + match self.insertion_parent() { + Some(p) if !self.doc.is_editable(p) => Err(EngineError::Other("the target layer is locked or hidden".into())), + parent => Ok(parent), + } + } /// The highlighted Layers panel rows that still exist (ids are reused after undo, so a /// remembered row must still be in the document). pub fn highlighted_rows(&self) -> Vec { diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 6ad69cf3e..20e73d962 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -78,6 +78,36 @@ fn art_in_a_sublayer_is_selected_as_objects_not_the_sublayer() { assert!(s.doc().unwrap().doc.node(a).is_none()); } +/// #1002: with no layer shown and unlocked, new art is refused (the document unchanged) rather +/// than put on a hidden or locked layer; another layer that takes art still gets it. +#[test] +fn new_art_is_refused_when_no_layer_is_shown_and_unlocked() { + for prop in ["visible", "locked"] { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id; + let copied = rect(&mut s, 0.0); + run(&mut s, "select.set", json!({"ids": [copied.0]})); + run(&mut s, "edit.copy", json!({})); + run(&mut s, "select.none", json!({})); + run(&mut s, "layer.setProps", json!({"ids": [layer.0], prop: prop == "locked"})); + let before = s.doc().unwrap().doc.clone(); + for (cmd, p) in [ + ("shape.ellipse", json!({"x": 10, "y": 10, "width": 20, "height": 20})), + ("shape.polarGrid", json!({"x": 10, "y": 10, "width": 20, "height": 20})), + ("text.create", json!({"x": 10, "y": 40, "text": "A"})), + ("edit.paste", json!({})), + ] { + let e = s.execute(cmd, &p).unwrap_err(); + assert_eq!(e.to_string(), "the target layer is locked or hidden", "{prop} {cmd}"); + } + assert_eq!(s.doc().unwrap().doc.layers, before.layers, "{prop}: nothing was added"); + let other = id(&run(&mut s, "layer.new", json!({}))); + run(&mut s, "layer.setCurrent", json!({"id": layer.0})); + let n = rect(&mut s, 50.0); + assert_eq!(parent(&s, n), Some(other), "{prop}"); + } +} + #[test] fn clicking_rows_at_any_depth_highlights_them_and_sets_the_current_layer() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b64e2878b..79021e6c7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3656,6 +3656,7 @@ theme Appearance Erscheinungsbild @msg the image's pixels don't match its size die Pixel des Bilds passen nicht zu seiner Größe @msg the layer has no objects to clip by die Ebene hat keine Objekte, an denen beschnitten werden könnte @msg the layer or group is locked die Ebene oder Gruppe ist gesperrt +@msg the target layer is locked or hidden die Zielebene ist gesperrt oder ausgeblendet @msg the layer to flatten into is hidden die Ebene, auf die reduziert werden soll, ist ausgeblendet @msg the object already has an opacity mask das Objekt hat bereits eine Deckkraftmaske @msg the object has no geometry das Objekt hat keine Geometrie diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cf34a1a30..9769b1107 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3759,6 +3759,7 @@ axis Both Ambos @msg the image's pixels don't match its size los píxeles de la imagen no coinciden con su tamaño @msg the layer has no objects to clip by la capa no tiene objetos con los que recortar @msg the layer or group is locked la capa o el grupo está bloqueado +@msg the target layer is locked or hidden la capa de destino está bloqueada u oculta @msg the layer to flatten into is hidden la capa en la que acoplar está oculta @msg the object already has an opacity mask el objeto ya tiene una máscara de opacidad @msg the object has no geometry el objeto no tiene geometría diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 67993dce7..74c185339 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3756,6 +3756,7 @@ axis Both Les deux @msg the image's pixels don't match its size les pixels de l’image ne correspondent pas à sa taille @msg the layer has no objects to clip by le calque n’a aucun objet servant à l’écrêtage @msg the layer or group is locked le calque ou le groupe est verrouillé +@msg the target layer is locked or hidden le calque cible est verrouillé ou masqué @msg the layer to flatten into is hidden le calque de destination de l’aplatissement est masqué @msg the object already has an opacity mask l’objet a déjà un masque d’opacité @msg the object has no geometry l’objet n’a pas de géométrie diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 094f6e514..5d423c7be 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3759,6 +3759,7 @@ axis Both Entrambi @msg the image's pixels don't match its size i pixel dell'immagine non corrispondono alle sue dimensioni @msg the layer has no objects to clip by il livello non ha oggetti con cui ritagliare @msg the layer or group is locked il livello o il gruppo è bloccato +@msg the target layer is locked or hidden il livello di destinazione è bloccato o nascosto @msg the layer to flatten into is hidden il livello in cui appiattire è nascosto @msg the object already has an opacity mask l'oggetto ha già una maschera di opacità @msg the object has no geometry l'oggetto non ha geometria diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e109c1ea3..ec1705826 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3497,6 +3497,7 @@ theme Appearance 外観 @msg no selection bounds 選択範囲に境界がありません @msg the layer has no objects to clip by レイヤーにクリッピングマスクとして使えるオブジェクトがありません @msg the layer or group is locked レイヤーまたはグループがロックされています +@msg the target layer is locked or hidden ターゲットレイヤーがロックされているか非表示です @msg the layer to flatten into is hidden 統合先のレイヤーが非表示になっています @msg there are no objects to release レイヤーに分配するオブジェクトがありません @msg no library `{id}` ライブラリ `{id}` が見つかりません diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6226e177c..14543a6cd 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3755,6 +3755,7 @@ axis Both Оба @msg the image's pixels don't match its size пиксели изображения не соответствуют его размеру @msg the layer has no objects to clip by в слое нет объектов для обрезки @msg the layer or group is locked слой или группа заблокированы +@msg the target layer is locked or hidden целевой слой заблокирован или скрыт @msg the layer to flatten into is hidden слой для объединения скрыт @msg the object already has an opacity mask у объекта уже есть непрозрачная маска @msg the object has no geometry у объекта нет геометрии diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bc0003687..f46baa1e2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3742,6 +3742,7 @@ axis Both Обидві @msg the image's pixels don't match its size пікселі зображення не відповідають його розміру @msg the layer has no objects to clip by у шарі немає об’єктів для обрізки @msg the layer or group is locked шар чи група заблоковані +@msg the target layer is locked or hidden цільовий шар заблокований або прихований @msg the layer to flatten into is hidden шар для об’єднання прихований @msg the object already has an opacity mask об’єкт вже має маску непрозорості. @msg the object has no geometry об’єкт не має геометрії From 7f17d980d45609275a8afedc4add58c4f6726712 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:31 +0700 Subject: [PATCH 34/97] #1003: MCP add_text colours type made on or in a path add_text passed `color` to text.create only, and text.createInPath built its own character style without a colour, so type on a path and path-based area type took the current fill. text.createInPath now uses the same new-type style as text.create (size, font, style, color, else the current fill), and add_text sends `color` on both routes. The MCP test draws a blue rectangle (the current fill), turns it into on-path and area type with color #ff0000, and reads red back through inspect_document. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- crates/engine/src/cmd/textedit.rs | 13 ++----------- crates/mcp/src/tests.rs | 16 ++++++++++++++++ crates/mcp/src/tools.rs | 5 +---- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0e054ab53..fca8ef63f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/textedit.rs b/crates/engine/src/cmd/textedit.rs index 0df32a979..7c0b41c7e 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}", has_doc, create_in_path ), @@ -485,16 +485,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { if !on_path && !path.is_closed() && path.bounds().is_none_or(|b| b.width() < 1.0 || b.height() < 1.0) { return Err(bad(C, "area type needs a path that encloses an area")); } - let mut style = CharStyle::default(); - if let Some(v) = p.get("size").and_then(Value::as_f64) { - style.size = v.clamp(0.1, 1296.0); - } - if let Some(f) = str_param(p, "font") { - style.font_family = f.to_string(); - } - if !s.paint.fill.is_none() && s.paint.fill != Paint::solid(vectorcraft_color::Color::WHITE) { - style.fill = s.paint.fill.clone(); - } + let style = super::create::new_type_style(s, p); let text = str_param(p, "text").unwrap_or("").to_string(); let start = match point_param(p, "at") { Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0), diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 25639ebd5..17b3773a8 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -312,6 +312,22 @@ fn inspect_document_reports_the_paint_of_type() { assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}"); } +/// #1003: type made on or in a path takes add_text's `color` too, over the current fill. +#[test] +fn add_text_on_or_in_a_path_takes_its_color() { + for mode in ["onPath", "area"] { + let mut s = server(); + let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 40, "y": 40, "width": 180, "height": 60, "fill": "#0000ff"})); + let path = serde_json::from_str::(&text_of(&r)).unwrap()["id"].clone(); + let r = call(&mut s, 3, "add_text", json!({"text": "Hello", "path": path, "mode": mode, "color": "#ff0000"})); + assert_eq!(r["isError"], false, "{r}"); + let id = serde_json::from_str::(&text_of(&r)).unwrap()["id"].clone(); + let v: Value = serde_json::from_str(&text_of(&call(&mut s, 4, "inspect_document", json!({})))).unwrap(); + let t = v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).cloned().unwrap(); + assert_eq!(t["fill"].as_str(), Some("#ff0000"), "{mode}: {t}"); + } +} + /// save_file says it saves in the document's own format or the one the path's extension picks. #[test] fn save_file_describes_the_formats_it_writes() { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b84358ce3..9ae42a4af 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -574,7 +574,7 @@ fn select_ids(b: &mut dyn Backend, a: &Args) -> Result<(), String> { fn add_text(b: &mut dyn Backend, a: &Args) -> Result { let text = a.get("text").and_then(Value::as_str).ok_or("missing string argument `text`")?; let mut style = json!({}); - for k in ["size", "font"] { + for k in ["size", "font", "color"] { if let Some(v) = a.get(k) { style[k] = v.clone(); } @@ -592,9 +592,6 @@ fn add_text(b: &mut dyn Backend, a: &Args) -> Result { for (k, v) in style.as_object().into_iter().flatten() { p[k.as_str()] = v.clone(); } - if let Some(c) = a.get("color") { - p["color"] = c.clone(); - } if let (Some(w), Some(h)) = (a.get("width"), a.get("height")) { p["area"] = json!({"width": w, "height": h}); } From 148ef1870af403c3a6d30accd5e6c04877c40f6f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:31 +0700 Subject: [PATCH 35/97] #1004: a connected app's screenshot renders the artboard asked for The desktop app's ui.render (MCP `screenshot` when connected) always rendered the first artboard and ignored `artboard`, which the headless backend honours. It now renders the 0-based `artboard` (the first without one, an error for one that doesn't exist), as headless does. control-protocol.md and the control.rs list show the parameter. The test drives ui.render through the control channel on a 64 x 64 and a 96 x 48 artboard: no index and 0 give 64 x 64, 1 gives 96 x 48 (48 x 24 at scale 0.5), 2 is an error. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- crates/ui-egui/src/control.rs | 47 ++++++++++++++++++++++++++++++++--- docs/control-protocol.md | 2 +- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fca8ef63f..b4ee24dc6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bdf2fd94a..6ca73099a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -16,8 +16,8 @@ //! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel` //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG //! comes back as `pngBase64` as well) -//! - `ui.render {path?, scale?, data?}`: render the active artboard headlessly (PNG; without a path, -//! or with `data: true`, as `pngBase64`) +//! - `ui.render {path?, scale?, artboard?, data?}`: render an artboard headlessly (`artboard`: its +//! 0-based index, default the first; PNG; without a path, or with `data: true`, as `pngBase64`) //! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit` //! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each //! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard) @@ -321,7 +321,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.render" => { let Some(st) = app.session.active() else { return err("no document") }; let doc = st.doc.clone(); - let Some(r) = doc.artboards.first().map(|a| a.rect) else { return err("no artboard") }; + // The artboard asked for (0-based, as headless), else the first. + let index = p.get("artboard").and_then(Value::as_u64).map_or(Some(0), |i| usize::try_from(i).ok()); + let Some(r) = index.and_then(|i| doc.artboards.get(i)).map(|a| a.rect) else { return err("no such artboard") }; let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0); if let Err(e) = vectorcraft_render::raster_size(r, scale) { return err(e); @@ -390,3 +392,42 @@ pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: None => json!({"ok": false, "error": "no writer configured"}), } } + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + use vectorcraft_engine::Session; + + use super::ControlRequest; + use crate::{Services, VectorcraftApp}; + + /// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked + /// for, at the scale asked for; the first without one. + #[test] + fn render_takes_the_artboard_asked_for() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx); + let mut request = |method: &str, params: Value| { + let (req, reply) = ControlRequest::new(method, params); + tx.send(req).unwrap(); + app.drain_control(&egui::Context::default()); + reply.try_recv().unwrap() + }; + let new = json!({"width": 64, "height": 64, "units": "Points", "artboards": 2}); + request("engine.execute", json!({"command": "file.new", "params": new})); + let board = json!({"index": 1, "x": 100, "y": 0, "width": 96, "height": 48}); + let r = request("engine.execute", json!({"command": "artboard.setProps", "params": board})); + assert_eq!(r["ok"], true, "{r}"); + for (params, size) in [ + (json!({}), [64, 64]), + (json!({"artboard": 0}), [64, 64]), + (json!({"artboard": 1}), [96, 48]), + (json!({"artboard": 1, "scale": 0.5}), [48, 24]), + ] { + let r = request("ui.render", params.clone()); + let got = [&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64); + assert_eq!(got, size.map(Some), "{params}: {r}"); + } + assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 549048af5..8ad6a9674 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,7 +56,7 @@ the two can still win that race. | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | | | `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | -| `ui.render` | `{path?, scale?, data?}` | render the artboard headlessly (PNG): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | +| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | From 33b6a6323d5e1fc0d06123648bef8e1a7e9cc636 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:32 +0700 Subject: [PATCH 36/97] #1005: vectorcraft-cli run ends quietly when its reader closes early `run` reported "Broken pipe" (on Windows "The pipe is being closed") and exited 1 when the reader of its stdout went away, unlike `commands` and the other subcommands, which end quietly. Its step printer now treats a closed pipe as the end of output, not an error: the remaining steps still run (an export is still written), unprinted, and the run exits 0. Other write errors and failing steps still fail. The test runs document.json, a rectangle and an SVG export with stdout closed: status 0, empty stderr, the SVG written; `run --cmd not-a-command` still fails. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- apps/vectorcraft-cli/src/main.rs | 16 +++++++++++++++- apps/vectorcraft-cli/tests/cli.rs | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b4ee24dc6..804916fd8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 1cf07964a..26139dcc8 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -292,7 +292,21 @@ fn run(args: &[String]) -> Result<(), String> { let mut h = Headless::new(); let mut out = std::io::stdout().lock(); - let mut emit = |v: Value| writeln!(out, "{v}").map_err(|e| e.to_string()); + // A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later + // steps (an export…) still run, unprinted. + let mut closed = false; + let mut emit = |v: Value| { + if closed { + return Ok(()); + } + match writeln!(out, "{v}") { + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => { + closed = true; + Ok(()) + } + r => r.map_err(|e| e.to_string()), + } + }; if let Some(path) = &input { let r = h.call("app.open", json!({"path": path})).map_err(|e| format!("open {path}: {e}"))?; emit(json!({"step": "open", "path": path, "result": r}))?; diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index d5bb25783..5b17de4bc 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -297,3 +297,21 @@ fn closed_stdout_ends_quietly() { let err = String::from_utf8_lossy(&out.stderr); assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status); } + +/// #1005: `run` whose reader has gone (`vectorcraft-cli run … | true`) ends quietly with status 0, +/// and its later steps still run; a failing step still fails. +#[test] +fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() { + let svg = tmp("closed-run.svg"); + let _ = std::fs::remove_file(&svg); + let rect = r#"{"x":1,"y":1,"width":5,"height":5}"#; + let args = ["run", "--cmd", "document.json", "--cmd", "shape.rectangle", "--params", rect, "--export", svg.to_str().unwrap()]; + let mut child = Command::new(BIN).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + drop(child.stdout.take()); + let out = child.wait_with_output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success() && err.is_empty(), "{:?}: {err}", out.status); + assert!(svg.is_file(), "the export after the closed reader still ran"); + let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap(); + assert!(!out.status.success()); +} From cacc9e4f36c24149c11b0325f5617935bbbb4c47 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:32 +0700 Subject: [PATCH 37/97] #1006: Select > All on Active Artboard selects on the active artboard The menu item sends no artboard, so the engine fell back to the first one even with another active in the Artboards panel. The frontend fills in the active artboard (the view's, as the Artboards panel and navigator show it) for select.allOnArtboard, as it already did for Paste in Place / in Front / in Back (#693); the two now share one block, and the index is kept within the artboards. An explicit artboard still wins; the engine is unchanged. The test makes artboard 2 active, then 1, and invokes the menu item each time, then passes artboard 1 explicitly with artboard 1 active. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- crates/ui-egui/src/lib.rs | 18 ++++++++++-------- crates/ui-egui/src/panels/artboards.rs | 20 ++++++++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 804916fd8..485f7fb7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d82fbbde1..0077af03a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -579,6 +579,16 @@ impl VectorcraftApp { return r; } let mut params = params; + // Paste in place, in front, in back (#693) and Select › All on Active Artboard (#1006): onto + // or on the active artboard, the view's. + if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack" | "select.allOnArtboard") + && params.get("artboard").is_none() + && self.view().is_some() + && let Some(n) = self.session.active().map(|d| d.doc.artboards.len()) + && let Some(p) = params.as_object_mut() + { + p.insert("artboard".into(), serde_json::json!(panels::artboards::selected(self, n))); + } if id.starts_with("edit.paste") { if let Err(e) = self.adopt_system_clipboard() { self.ui.status = e.clone(); @@ -592,14 +602,6 @@ impl VectorcraftApp { { p.insert("center".into(), serde_json::json!([c.x, c.y])); } - // In place, in front, in back: onto the active artboard (#693). - if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack") - && params.get("artboard").is_none() - && let Some(i) = self.view().map(|v| v.artboard) - && let Some(p) = params.as_object_mut() - { - p.insert("artboard".into(), serde_json::json!(i)); - } if let Some(r) = dialogs::swatch_conflict::ask(self, id, ¶ms) { return r; } diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index db3be6885..ab99fbaa6 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -235,6 +235,26 @@ mod tests { crate::menus::invoke(&mut app, "view.goToArtboard", json!({"index": "previous"})); assert_eq!(selected(&app, 2), 0, "and the panel follows the navigator"); } + + /// #1006: Select › All on Active Artboard selects the art of the artboard made active here, + /// and an explicit artboard still wins. + #[test] + fn all_on_active_artboard_takes_the_active_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 2})).unwrap(); + let mut rect = |x| app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + let (a, b) = (rect(10), rect(130)); + let picked = |app: &mut VectorcraftApp, params| { + crate::menus::invoke(app, "select.allOnArtboard", params); + let ids: Vec = app.session.active().unwrap().selection.objects.iter().map(|n| n.0).collect(); + json!(ids) + }; + select(&mut app, 1); + assert_eq!(picked(&mut app, json!({})), json!([b])); + select(&mut app, 0); + assert_eq!(picked(&mut app, json!({})), json!([a])); + assert_eq!(picked(&mut app, json!({"artboard": 1})), json!([b])); + } /// Double-clicking a row's number goes to its artboard and its name renames it; a row's /// Options button edits that artboard with the Artboard tool, and the tool's artboard is the /// active one. From 0497c9e46db4e94806aca22a3425a3e9bc6f74fd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:32 +0700 Subject: [PATCH 38/97] #1007: Reverse Gradient mirrors off-centre midpoints Reversal read the midpoints after Gradient::reverse had reordered the stops, so each stop took the wrong segment's midpoint: red-to-blue with its midpoint at 20% reversed to a 50% midpoint instead of 80%, moving the transition. Gradient::reverse now mirrors each segment's midpoint with it, so a reversed gradient is the same ramp the other way round; paint.editGradient's own (wrong) pass is gone, and the SVG importer's reflect spread, which had a correct copy of the same mirroring, uses it too. Tests: two- and three-stop gradients with off-centre midpoints sample the same reversed (t vs 1 - t) and come back after two reversals; paint.editGradient reverse turns a 0.2 midpoint into 0.8. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- crates/color/src/gradient.rs | 38 ++++++++++++++++++++++++++-- crates/engine/src/cmd/gradient.rs | 7 +---- crates/engine/src/tests_panelcmds.rs | 7 +++++ crates/svg/src/import/spread.rs | 16 +++--------- 5 files changed, 49 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 485f7fb7c..77a3a3e09 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/color/src/gradient.rs b/crates/color/src/gradient.rs index 3f9108723..1c2b96c6e 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -131,10 +131,18 @@ impl Gradient { std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid) }) } + /// The same ramp the other way round: the stops in reverse order at mirrored offsets, each + /// segment's midpoint mirrored with it. pub fn reverse(&mut self) { self.stops.reverse(); - for s in &mut self.stops { - s.offset = 1.0 - s.offset; + for i in 0..self.stops.len() { + // A midpoint belongs to the segment after its stop: reversed, stop i's segment is the + // one the next stop (not yet mirrored) had, measured from the other end. + let mid = self.stops.get(i + 1).map_or(0.5, |n| 1.0 - n.midpoint); + if let Some(s) = self.stops.get_mut(i) { + s.offset = 1.0 - s.offset; + s.midpoint = mid; + } } } pub fn sort(&mut self) { @@ -569,6 +577,32 @@ mod tests { assert_eq!(g.stops[0].offset, 0.0); } + /// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same + /// the other way round. + #[test] + fn reverse_mirrors_midpoints() { + let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; + let two = Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)] }; + let three = + Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)] }; + for g in [two, three] { + let mut r = g.clone(); + r.reverse(); + let mids: Vec = r.stops.iter().map(|s| s.midpoint).collect(); + let want: Vec = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect(); + assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}"); + for i in 0..=20 { + let t = i as f32 / 20.0; + let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t)); + let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1); + assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}"); + } + r.reverse(); + let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6); + assert!(back, "reversed twice, it is as it was: {:?}", r.stops); + } + } + fn close(a: Point, b: Point) -> bool { a.distance(b) < 1e-9 } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 6942edff1..aa0a85364 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -270,13 +270,8 @@ pub(crate) fn apply_gradient_edit_in( gp.swatch = None; } if bool_or(p, "reverse", false) { + // Midpoints mirrored with their segments. gp.gradient.reverse(); - // Midpoints belong to the segment to the right; mirror them. - let n = gp.gradient.stops.len(); - let mids: Vec = gp.gradient.stops.iter().map(|s| s.midpoint).collect(); - for i in 0..n { - gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 }; - } } // Angle and aspect edits keep the focal point in its place in the extent ellipse. let before = gp.geom; diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index 1844a8100..fc2c4e6aa 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -56,6 +56,13 @@ fn edit_gradient_angle_reverse_and_errors() { s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap(); let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() }; assert_eq!(g.gradient.stops[0].color.to_hex(), "#000000"); + // #1007: an off-centre midpoint is mirrored, not reset. + let stops = json!([{"offset": 0, "color": "#ff0000", "midpoint": 0.2}, {"offset": 1, "color": "#0000ff"}]); + s.execute("paint.editGradient", &json!({"stroke": false, "stops": stops})).unwrap(); + s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap(); + let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() }; + assert_eq!(g.gradient.stops[0].color.to_hex(), "#0000ff"); + assert!((g.gradient.stops[0].midpoint - 0.8).abs() < 1e-6, "{:?}", g.gradient.stops); assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#fff"}]})).is_err()); assert!(s.execute("paint.editGradient", &json!({"kind": "conic"})).is_err()); } diff --git a/crates/svg/src/import/spread.rs b/crates/svg/src/import/spread.rs index b88ecf3e7..fa4f70159 100644 --- a/crates/svg/src/import/spread.rs +++ b/crates/svg/src/import/spread.rs @@ -2,7 +2,7 @@ //! repeated (every other period mirrored, for reflect) as many times as the painted area needs, //! and the gradient stretched over those periods. -use vectorcraft_color::{GradientGeom, GradientKind, GradientStop}; +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop}; use vectorcraft_geom::{Point, Rect}; /// Most periods a gradient is expanded to. @@ -80,17 +80,9 @@ fn whole_period(stops: &[GradientStop]) -> Vec { /// A period mirrored: the stops in reverse, each midpoint measured from the other side. fn mirror(period: &[GradientStop]) -> Vec { - let rev: Vec<&GradientStop> = period.iter().rev().collect(); - rev.iter() - .enumerate() - .map(|(i, s)| GradientStop { - offset: 1.0 - s.offset, - // The midpoint from this stop to the next one is the mirror of the one the next stop - // had towards this one. - midpoint: rev.get(i + 1).map_or(0.5, |next| 1.0 - next.midpoint), - ..(*s).clone() - }) - .collect() + let mut g = Gradient { kind: GradientKind::Linear, stops: period.to_vec() }; + g.reverse(); + g.stops } #[cfg(test)] From fd52ca32c7934b1da04391c1a0106f9e6f72a786 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:32 +0700 Subject: [PATCH 39/97] #1008: a polar grid with no radial dividers draws none shapes::polar_grid drew at least one radial divider (`radial.max(1)`), so radial 0, which the command and the Polar Grid tool (Left arrow while dragging) accept, still drew a spoke. Zero now draws none; the loop never divides by it. The test checks 0, 1 and 2 radial dividers with no concentric ones give 1, 2 and 3 paths. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- crates/geom/src/shapes.rs | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 77a3a3e09..3647e7c93 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 64db36827..d123fc4e8 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -235,8 +235,9 @@ pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec { let f = i as f64 / (concentric + 1) as f64; out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f)))); } - for i in 0..radial.max(1) { - let a = -PI / 2.0 + TAU * i as f64 / radial.max(1) as f64; + // No dividers at 0 (the loop never divides by it). + for i in 0..radial { + let a = -PI / 2.0 + TAU * i as f64 / radial as f64; out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin()))); } out @@ -402,6 +403,10 @@ mod tests { fn grids() { assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11); assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11); + // #1008: no radial dividers is none, only the outer ellipse without concentric ones. + for (radial, len) in [(0, 1), (1, 2), (2, 3)] { + assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, radial).len(), len, "{radial}"); + } } #[test] From b23a402df8244f00b7cd2b29953e78df7ffd87ad Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:33 +0700 Subject: [PATCH 40/97] #1009: Use Precise Cursors makes the Pen's join pointer a crosshair Cursor::precise listed every Pen state but PenJoin (added later, #794), so with Use Precise Cursors on the Pen still showed its join badge over another open path's end. PenJoin is a crosshair too under the preference. The Pen join test now also checks the pointer over the other path's end is the crosshair with the preference on (and PenJoin with it off, as before). Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- crates/engine/src/tests_draw2.rs | 4 ++++ crates/tools/src/lib.rs | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3647e7c93..04eab3e42 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 514ad9532..7eea62a8d 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -870,6 +870,10 @@ fn the_pen_continues_any_open_path_and_joins_another() { assert_eq!(s.doc().unwrap().selection.objects, vec![a]); assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::PenJoin); assert_eq!(s.cursor(Point::new(350.0, 200.0), none, v), Cursor::Pen, "the middle of the other path"); + // #1009: Use Precise Cursors makes the join pointer a crosshair too. + s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap(); + assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::Crosshair); + s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap(); assert_eq!(gesture(&mut s, &[(300.0, 200.0)], none), 1); let pts = |s: &Session, id| path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|x| (x.p.x, x.p.y))).collect::>(); assert_eq!(pts(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 200.0), (400.0, 200.0)]); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 9f62a366b..4febf31d9 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -485,6 +485,7 @@ impl Cursor { | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue + | Cursor::PenJoin | Cursor::PenConvert | Cursor::Eyedropper | Cursor::Slice From a0438f73f941966bb5244e0624b5c0271473ec52 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:33 +0700 Subject: [PATCH 41/97] #1010: SVGs with one absolute and one unitless root side import unstretched A root such as width="96pt" height="96" took CSS pixels (0.75 pt) along x and points along y, so a square came in 24 x 32 pt and the artboard 96 x 96 instead of 96 x 72; with a viewBox the art was stretched too. When either root side is absolute, both axes now take CSS pixels at 96 per inch (usvg already parses at that resolution): the unitless or px side is the same CSS length it would be in a browser. The root scale is one number. The test opens width="96pt" with height 96, 96px and 72pt, and width 128 with height 72pt: a 96 x 72 artboard and a 24 x 24 square; with viewBox 0 0 32 32 a 72 x 72 square at x 12 (meet), and with preserveAspectRatio="none" the whole artboard. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- crates/svg/src/import.rs | 21 +++++++++++---------- crates/svg/src/lib.rs | 5 +++-- crates/svg/tests/roundtrip.rs | 25 +++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 04eab3e42..7e3f47101 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/svg/src/import.rs b/crates/svg/src/import.rs index d03e29aa4..a35292785 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -47,8 +47,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec Result<(Document, Vec Result<(Document, Vec| matches!(u, Some(U::In | U::Cm | U::Mm | U::Pt | U::Pc)); let (w, h) = (unit("width"), unit("height")); - let k = |u| if physical(u) { PT_PER_IN / CSS_PX_PER_IN } else { 1.0 }; - let dpi = if physical(w) || physical(h) { CSS_PX_PER_IN } else { PT_PER_IN }; + let (dpi, k) = if physical(w) || physical(h) { (CSS_PX_PER_IN, PT_PER_IN / CSS_PX_PER_IN) } else { (PT_PER_IN, 1.0) }; let unit = match w { Some(U::Mm) => Unit::Millimeters, Some(U::Cm) => Unit::Centimeters, @@ -238,7 +239,7 @@ impl RootUnits { Some(U::Pc) => Unit::Picas, _ => Unit::Pixels, }; - Self { dpi, k: (k(w), k(h)), unit } + Self { dpi, k, unit } } } diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index aae41bdab..3789d8f4a 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -41,8 +41,9 @@ //! * One CSS pixel (user unit) is one point, as on export, and absolute lengths (`in`, `cm`, `mm`, //! `pt`, `pc`) keep their physical size (72 pt per inch): `font-size="12pt"` is 12 pt. A root //! `width`/`height` in absolute units keeps its physical size, so a 210 mm SVG opens on a 210 mm -//! artboard, its user units being CSS pixels of it (96 per inch). The document's units follow the -//! unit of the root `width` (pixels when it has none). +//! artboard, its user units being CSS pixels of it (96 per inch), the other side's too when only +//! one is absolute. The document's units follow the unit of the root `width` (pixels when it has +//! none). //! * `` imports as a luminance opacity mask; a mask we exported keeps its options and art //! (its `data-vectorcraft-mask="noclip invert"` lists the options that differ from clipping and //! not inverted). Nested clip paths (``) are clip groups in clip groups. diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 3ae90e787..c7433a7a0 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -811,6 +811,31 @@ fn import_absolute_units_keep_their_physical_size() { } } +/// #1010: a root with one absolute side and one unitless or px side opens as if both were +/// absolute (CSS pixels at 96 per inch), so nothing is stretched and the viewport keeps its size. +#[test] +fn import_mixed_root_units_scale_both_sides_alike() { + let rect = |d: &Document| art(d)[0].geometric_bounds().unwrap(); + for (size, board) in [ + (r#"width="96pt" height="96""#, Rect::new(0.0, 0.0, 96.0, 72.0)), + (r#"width="96pt" height="96px""#, Rect::new(0.0, 0.0, 96.0, 72.0)), + (r#"width="96pt" height="72pt""#, Rect::new(0.0, 0.0, 96.0, 72.0)), + (r#"width="128" height="72pt""#, Rect::new(0.0, 0.0, 96.0, 72.0)), + ] { + let svg = + |extra: &str| format!(r##""##); + let d = import(&svg("")).unwrap(); + assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect); + assert!(close_rect(rect(&d), Rect::new(0.0, 0.0, 24.0, 24.0), 1e-6), "{size}: {:?}", rect(&d)); + // With a viewBox: fitted uniformly (xMidYMid meet), unless preserveAspectRatio is none. + let d = import(&svg(r#"viewBox="0 0 32 32""#)).unwrap(); + assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect); + assert!(close_rect(rect(&d), Rect::new(12.0, 0.0, 84.0, 72.0), 1e-6), "{size} viewBox: {:?}", rect(&d)); + let d = import(&svg(r#"viewBox="0 0 32 32" preserveAspectRatio="none""#)).unwrap(); + assert!(close_rect(rect(&d), board, 1e-6), "{size} none: {:?}", rect(&d)); + } +} + #[test] fn full_document_roundtrip_is_stable() { // export → import → export produces identical SVG. From 683b1e98453e4b7d355be6248cc9601d4d7ed699 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:33 +0700 Subject: [PATCH 42/97] #1011: DXF US survey inches, yards and miles import at their size $INSUNITS 22, 23 and 24 (US survey inch, yard and mile, as Autodesk's INSUNITS reference lists them) had no size or name, so the drawing opened as unitless (millimetres, or inches with $MEASUREMENT 0) and document.dxfInfo said Unitless. They now derive from the US survey foot (1200/3937 m) like code 21, with their names. The test reads each survey code's unit name and default scale and opens a survey-inch line at 7200.0144 pt. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- crates/cad/src/import.rs | 5 ++++- crates/cad/src/import/build.rs | 7 ++++++- crates/cad/src/import/tests.rs | 14 ++++++++++++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7e3f47101..f79f0acc1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/cad/src/import.rs b/crates/cad/src/import.rs index 721586e5f..b79670934 100644 --- a/crates/cad/src/import.rs +++ b/crates/cad/src/import.rs @@ -129,7 +129,7 @@ fn default_ratio(h: &reader::Header) -> (Unit, f64) { /// The name of `$INSUNITS` code `code`. fn units_name(code: i64) -> &'static str { - const NAMES: [&str; 22] = [ + const NAMES: [&str; 25] = [ "Unitless", "Inches", "Feet", @@ -152,6 +152,9 @@ fn units_name(code: i64) -> &'static str { "Light years", "Parsecs", "US survey feet", + "US survey inches", + "US survey yards", + "US survey miles", ]; usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless") } diff --git a/crates/cad/src/import/build.rs b/crates/cad/src/import/build.rs index 449b95f7c..404b6a161 100644 --- a/crates/cad/src/import/build.rs +++ b/crates/cad/src/import/build.rs @@ -66,6 +66,8 @@ pub(crate) struct Build<'d, 'a> { /// The unit of `$INSUNITS` code `code` in points (0, unitless: none). pub(crate) fn insunits_points(code: i64) -> Option { let m = 72.0 / 0.0254; + // The US survey foot: exactly 1200/3937 m. + let survey_ft = m * 1200.0 / 3937.0; Some(match code { 1 => 72.0, 2 => 864.0, @@ -87,7 +89,10 @@ pub(crate) fn insunits_points(code: i64) -> Option { 18 => m * 1.495_978_707e11, 19 => m * 9.460_730_472_580_8e15, 20 => m * 3.085_677_581e16, - 21 => m * 1200.0 / 3937.0, + 21 => survey_ft, + 22 => survey_ft / 12.0, + 23 => survey_ft * 3.0, + 24 => survey_ft * 5280.0, _ => return None, }) } diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index 0e69cb546..5cddf427e 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -128,6 +128,20 @@ fn units_choose_the_default_scale() { let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap(); assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns")); assert!((um.scale - 1000.0).abs() < 1e-6); + // #1011: US survey inches, yards and miles (from the survey foot, 1200/3937 m), not unitless. + let survey_ft = 72.0 / 0.0254 * 1200.0 / 3937.0; + for (code, name, pt) in [ + ("21", "US survey feet", survey_ft), + ("22", "US survey inches", survey_ft / 12.0), + ("23", "US survey yards", survey_ft * 3.0), + ("24", "US survey miles", survey_ft * 5280.0), + ] { + let i = info(&drawing(&[(9, "$INSUNITS"), (70, code)], &[], &[], LINE)).unwrap(); + assert_eq!((i.unit, i.units.as_str()), (Unit::Millimeters, name)); + assert!((i.scale * pt - 72.0 / 25.4).abs() < 1e-9, "{name}: 1 mm is {} units", i.scale); + } + let survey_in = open(&drawing(&[(9, "$INSUNITS"), (70, "22")], &[], &[], LINE), &ImportOptions::default()).document; + assert!((survey_in.artboards[0].rect.width() - 7200.0144000288).abs() < 1e-6, "{:?}", survey_in.artboards[0].rect); let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap(); assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0)); // A ratio: 1 cm = 10 units is 1:1 in millimetres again. From a3316d2383377e0fa329e888012e6d8180259223 Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:43:43 +0530 Subject: [PATCH 43/97] Links: wake for the first look at a link placed while idle The link watch wakes the app every two seconds only while the last look found links (068a934, #967). A file placed less than two seconds after a look that found none asked for no more frames, so while the window sat idle nothing stamped it; a change made meanwhile was taken for the file as first seen and never acted on. The watch now remembers the active document's uid and revision at each look and, when they differ on a later frame, asks for one wake when the next look is due. There is still no per-frame walk of the links, and no two-second wakes for a document without links. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 13 ++++++- crates/ui-egui/src/tests_place.rs | 65 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0077af03a..be5c79ec5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -788,6 +788,8 @@ impl VectorcraftApp { return; } let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY); + // The active document as the last look saw it: its uid and revision. + let (seen_key, doc) = (egui::Id::new("linkWatch.seen"), self.session.active().map(|d| (d.uid, d.revision))); if now - last >= 2.0 || (focused && !was_focused) { // Walking the document's links costs a pass over it: only when a look is due. // `start_link_scan` starts none for a document without links, which then doesn't @@ -795,12 +797,21 @@ impl VectorcraftApp { ctx.data_mut(|d| d.insert_temp(last_key, now)); self.session.start_link_scan(); let links = self.session.link_scan.is_some(); - ctx.data_mut(|d| d.insert_temp(egui::Id::new("linkWatch.links"), links)); + ctx.data_mut(|d| { + d.insert_temp(egui::Id::new("linkWatch.links"), links); + d.insert_temp(seen_key, doc); + }); } if self.session.link_scan.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } else if ctx.data(|d| d.get_temp::(egui::Id::new("linkWatch.links"))).unwrap_or(false) { ctx.request_repaint_after(std::time::Duration::from_millis(2000)); + } else if ctx.data(|d| d.get_temp::>(seen_key)).is_some_and(|seen| seen != doc) { + // The document changed (a file placed into one without links) or another came to the + // front since that look: wake for the next one even if the app sits idle until then, + // or a file placed and changed meanwhile is first seen after the change, which is then + // never acted on. + ctx.request_repaint_after(std::time::Duration::from_secs_f64((last + 2.0 - now).clamp(0.0, 2.0))); } } diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index 5577bd763..d08044e4e 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -546,3 +546,68 @@ fn crop_image_shows_a_box_that_apply_crops_to() { app.run("select.none", json!({})).unwrap(); assert!(!crate::menus::enabled(&app, "ui.cropImage") && app.run("ui.cropImage", json!({})).is_err()); } + +/// One headless frame of the whole window at `time` → how long the app asked to wait before the +/// next one. +fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64) -> std::time::Duration { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map_or(std::time::Duration::MAX, |v| v.repaint_delay) +} + +/// The window sits idle for `secs` from `*time` (a frame now, then only the frames the app asks +/// for) or until `done` → whether it was. +fn idle(app: &mut VectorcraftApp, ctx: &egui::Context, time: &mut f64, secs: f64, done: &dyn Fn(&VectorcraftApp) -> bool) -> bool { + let end = *time + secs; + loop { + let wait = frame_at(app, ctx, *time); + if done(app) { + return true; + } + let next = *time + wait.as_secs_f64().max(1.0 / 60.0); + if next > end { + *time = end; + return false; + } + if app.session.link_scan.is_some() { + // The look runs on a worker thread, in real time. + std::thread::sleep(std::time::Duration::from_millis(5)); + } + *time = next; + } +} + +/// A file placed into a document whose last look found no links, then changed by another app +/// while the window sits idle in the background, follows Update Links: Automatically: the app +/// wakes itself to see the new link before the change, so the change isn't taken for the file as +/// first seen. +#[test] +fn a_file_placed_while_idle_follows_its_changes() { + let mut app = app(); + app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap(); + let ctx = egui::Context::default(); + let mut time = 0.0; + idle(&mut app, &ctx, &mut time, 3.0, &|_| false); + // A look finds the new document without links: no wakes of its own for it. + assert_eq!(frame_at(&mut app, &ctx, time), std::time::Duration::MAX, "{:?}", ctx.repaint_causes()); + // Half a second later, before the next look is due, a file is placed. The place itself is a + // frame (as any command); then nothing happens for 3 s. + time += 0.5; + let path = temp_file("idle-watch.png", &png(40, 30, 72.0)); + app.run("file.place", json!({"path": path})).unwrap(); + let id = app.session.active().unwrap().selection.objects[0]; + idle(&mut app, &ctx, &mut time, 3.0, &|_| false); + std::thread::sleep(std::time::Duration::from_millis(30)); + let changed = png(60, 30, 72.0); + std::fs::write(&path, &changed).unwrap(); + let hash = vectorcraft_doc::links::hash_bytes(&changed); + let updated = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + matches!(&st.doc.node(id).unwrap().kind, NodeKind::Image(im) if im.link.as_ref().and_then(|l| l.hash.as_deref()) == Some(hash.as_str())) + }; + assert!(idle(&mut app, &ctx, &mut time, 10.0, &updated), "the changed file was never read again"); +} From d5bc4ceeee749a9ceb1973cf0a02b343a8fc9eec Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:48:10 +0530 Subject: [PATCH 44/97] contributors: KattKnight's display name Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 7477eff37..45cf1695c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -58,3 +58,7 @@ sync_github_name = false real_name = "Fabio William Conceição" display_name = "Messhias" sync_github_name = false + +[people.KattKnight] +display_name = "KattKnight (ayla.dsc)" +sync_github_name = false From 340200a6ecb8acdc75f3d331683565c6e9d4083e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 04:14:12 +0700 Subject: [PATCH 45/97] ROADMAP: links followed from the first look (#1031) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index f79f0acc1..c5c631368 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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 | From adad531adb4e0cd34442e3f1d8b80d6de5b19f5e Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Sat, 10 Oct 2026 18:23:07 +0000 Subject: [PATCH 46/97] PDF import: a font the file doesn't carry keeps the style its name says A PDF that names a font without embedding it (here a standard Helvetica) is drawn with a stand-in, so comparing its glyphs with the installed family's styles picks whichever style happens to look most like the stand-in: with a large Helvetica family installed, Helvetica became Helvetica CE Medium (tests_import_fidelity::text_becomes_point_type failed on such a Mac). Fonts without a font program now keep the face their name resolves to; embedded fonts are still compared as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/pdf/src/import.rs | 16 +++++++++++----- crates/pdf/src/import_scan.rs | 29 ++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 7a5b3ab89..a8a39ebaf 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -21,7 +21,7 @@ use vectorcraft_geom::{FillRule, PathData}; use crate::import_color::{Colors, Native}; use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover}; -use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key}; +use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key}; use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient}; use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright}; use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs}; @@ -692,8 +692,9 @@ struct Builder<'p> { /// Mask keys of CMYK images whose mask turned out to hide nothing. opaque: HashSet, warnings: Vec, - /// Fonts by cache key → base font name (from [`scan_page`]). - fonts: HashMap, + /// Fonts by cache key → base font name and whether the file carries its glyphs (from + /// [`scan_page`]). + fonts: FontNames, font_names: HashMap, families: Option, missing_fonts: Vec, @@ -1346,7 +1347,7 @@ impl<'p> Builder<'p> { return n.clone(); } let data = o.font_data(); - let name = self.fonts.get(&key).cloned().or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone())); + let name = self.fonts.get(&key).map(|(n, _)| n.clone()).or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone())); let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic)); // A subset's six-letter tag. let ps = name.as_deref().map(|n| match n.split_once('+') { @@ -1429,7 +1430,12 @@ impl<'p> Builder<'p> { let upright = width.is_some_and(|w| w.abs() <= 1.0); let top = m * kurbo::Point::new(f64::from(width.filter(|w| *w > 1.0).unwrap_or(1000.0)) * 0.5, 880.0); let mut info = self.font_name(key, o); - if let Some(face) = &info.face { + // A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which + // style of the installed family it is, so the name decides. + let embedded = self.fonts.get(&key).is_none_or(|(_, e)| *e); + if let Some(face) = &info.face + && embedded + { let decided = match self.matched.get(&key) { Some(m) => m.clone(), // A glyph that can't be compared (several characters, a vertical form) decides diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index d2ec2e810..667274e88 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -248,7 +248,7 @@ struct Walker<'w, 'p> { /// The groups that are off for the interpreter (none once [`all_on`] turned them on). off: HashSet, xref: &'p hayro_syntax::xref::XRef, - fonts: &'w mut HashMap, + fonts: &'w mut FontNames, visible: Vec, ops: usize, max_ops: usize, @@ -296,7 +296,7 @@ impl Walker<'_, '_> { if let Some(f) = res.fonts.get::>(key.as_ref()) && let Some(name) = f.get::>(b"BaseFont") { - self.fonts.entry(f.cache_key()).or_insert_with(|| String::from_utf8_lossy(name.as_ref()).into_owned()); + self.fonts.entry(f.cache_key()).or_insert_with(|| (String::from_utf8_lossy(name.as_ref()).into_owned(), embeds_glyphs(&f))); } } } @@ -372,13 +372,32 @@ impl Walker<'_, '_> { } } +/// The fonts of a document by cache key: base font name, and whether the file carries the glyphs +/// ([`embeds_glyphs`]). +pub(crate) type FontNames = HashMap; + +/// Whether font `f` carries its own glyphs: a font program in its descriptor (a composite font's in +/// its descendant's), or a Type 3 font's glyph procedures. A font that doesn't is drawn with a +/// stand-in, whose glyphs say nothing about which installed face the document means. +fn embeds_glyphs(f: &Dict<'_>) -> bool { + if f.get::>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3") { + return true; + } + // A font program is a stream, so always an indirect object. + let program = |d: &Dict<'_>| { + d.get::>(b"FontDescriptor").is_some_and(|fd| [&b"FontFile"[..], b"FontFile2", b"FontFile3"].iter().any(|k| fd.get_ref(*k).is_some())) + }; + program(f) || f.get::>(b"DescendantFonts").and_then(|a| a.iter::>().next()).is_some_and(|d| program(&d)) +} + /// Walk `page` as the interpreter will: `all_on` when [`all_on`] turned every group on. Fonts -/// found are noted in `fonts` (their cache key → base font name). -pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut HashMap) -> Scan { +/// found are noted in `fonts` (their cache key → base font name, and whether the file carries its +/// glyphs). +pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut FontNames) -> Scan { walk_page(page, ocgs, all_on, fonts).out } -fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap) -> Walker<'w, 'p> { +fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames) -> Walker<'w, 'p> { let off = if all_on { HashSet::new() } else { ocgs.off.clone() }; let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, max_ops: max_ops(), out: Scan::default(), hidden_form: false }; w.walk(page.typed_operations(), page.resources(), 0); From 04e714e9ddfe3c7469db754ed6e204b2205e731a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 04:14:45 +0700 Subject: [PATCH 47/97] ROADMAP: unembedded PDF fonts keep their named style (#1023) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index c5c631368..510f88f59 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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 | From a22bced150146d42d94438d9abaceb788d033c4d Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Sat, 10 Oct 2026 17:35:54 +0000 Subject: [PATCH 48/97] Guides: Shift selects ruler guides and art together A Shift-click on a guide adds it to the selected art (guide.select toggle keeps the art), and Shift-adding or -removing art keeps the selected guides; a new selection still clears them. With both selected, Delete deletes the art and the guides in one undo step (not when ids are given). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/doc/src/selection.rs | 24 ++++++++++++++++---- crates/engine/src/cmd/docmenu.rs | 15 +++++-------- crates/engine/src/cmd/edit.rs | 6 +++++ crates/engine/src/tests_menucmds.rs | 34 +++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index a64b17aa5..e1e98de1e 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -29,8 +29,9 @@ pub struct Selection { /// clears them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub slices: Vec, - /// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. They are - /// selected on their own: any change to the object selection clears them. + /// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. A new + /// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so + /// guides and objects can be selected together, in either order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub guides: Vec, } @@ -66,7 +67,6 @@ impl Selection { pub fn add(&mut self, id: NodeId) { self.target = None; self.slices.clear(); - self.guides.clear(); if !self.objects.contains(&id) { self.objects.push(id); } @@ -74,7 +74,6 @@ impl Selection { pub fn remove(&mut self, id: NodeId) { self.target = None; self.slices.clear(); - self.guides.clear(); self.objects.retain(|x| *x != id); self.anchors.remove(&id); if self.key == Some(id) { @@ -99,6 +98,15 @@ impl Selection { } } } + /// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a + /// Shift-click on a guide). + pub fn toggle_guide(&mut self, i: usize) { + if let Some(k) = self.guides.iter().position(|g| *g == i) { + self.guides.remove(k); + } else { + self.guides.push(i); + } + } pub fn toggle(&mut self, id: NodeId) { if self.contains(id) { self.remove(id) } else { self.add(id) } } @@ -180,8 +188,16 @@ mod tests { s.set_guides([2, 0, 2]); assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0])); assert!(s.has_objects_or_guides() && s.is_empty()); + // Shift-adding an object keeps them; a new selection doesn't. s.add(NodeId(1)); + assert_eq!(s.guides, vec![2, 0]); + s.set([NodeId(1)]); assert!(s.guides.is_empty()); + // Toggled, a guide joins (or leaves) the selected objects. + s.toggle_guide(0); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0])); + s.toggle_guide(0); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![])); // Pruning drops guides the document no longer has. let mut d = Document::new(100.0, 100.0); d.guides.push(crate::Guide::new(true, 10.0)); diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index bf38e4455..973d501d5 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -178,7 +178,7 @@ pub fn specs() -> Vec { "Select Guides", [], None, - "{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead → {selected: [index…]}", + "{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}", guides_unlocked, guide_select ), @@ -539,15 +539,12 @@ fn guide_select(s: &mut Session, p: &Value) -> Result { let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?; let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::>>()?; if bool_or(p, "toggle", false) { - let mut now = s.doc()?.selection.guides.clone(); - for i in picked { - if let Some(k) = now.iter().position(|g| *g == i) { - now.remove(k); - } else { - now.push(i); + // Added to (or taken from) what is selected, the art included. + s.select(|_, sel| { + for i in picked { + sel.toggle_guide(i); } - } - s.select(|_, sel| sel.set_guides(now))?; + })?; } else { s.select(|_, sel| sel.set_guides(picked))?; } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d5e1b18ab..7c22774b6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -268,6 +268,8 @@ fn clear(s: &mut Session, p: &Value) -> Result { return super::docmenu::guide_remove(s, &json!({})); } let st = s.doc()?; + // Guides selected with the art go with it (not when ids are given). + let with_guides = explicit.is_none(); let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc)); // Keep the selected ids themselves, without promoting compound members. Delete a selected // ancestor just once; its selected descendants leave with it. Layers themselves are kept. @@ -284,6 +286,10 @@ fn clear(s: &mut Session, p: &Value) -> Result { for id in &ids { d.remove(*id)?; } + if with_guides { + let gone = sel.guides.clone(); + d.retain_guides(sel, |i, _| !gone.contains(&i)); + } sel.clear(); Ok(()) })?; diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 2d92bb2bc..d852915e2 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -823,6 +823,40 @@ fn ruler_guides_select_move_and_delete() { assert!(matches!(s.execute("edit.clear", &json!({})), Err(EngineError::Disabled(..)))); } +/// Shift selects ruler guides and art together, in either order; Delete takes both in one step. +#[test] +fn guides_and_art_are_shift_selected_together() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 40.0, 20.0); + let b = rect(&mut s, 70.0, 50.0, 60.0, 30.0); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + // Art first, then a Shift-click on a guide: the art stays selected. + sel(&mut s, &[a]); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + assert_eq!((selected(&s), selected_guides(&s)), (vec![a], vec![0])); + // A guide first, then Shift-added art: the guide stays selected. + s.execute("guide.select", &json!({"indexes": [1]})).unwrap(); + s.execute("select.add", &json!({"ids": [b.0]})).unwrap(); + assert_eq!((selected(&s), selected_guides(&s)), (vec![b], vec![1])); + // A new selection drops them. + sel(&mut s, &[a]); + assert!(selected_guides(&s).is_empty()); + // Delete takes the art and the guides, in one step; undo brings both back. + s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap(); + let n = undo_len(&s); + s.execute("edit.clear", &json!({})).unwrap(); + assert_eq!((guides(&s), undo_len(&s)), (vec![(true, 200.0)], n + 1)); + assert!(s.doc().unwrap().doc.node(a).is_none() && s.doc().unwrap().doc.node(b).is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 2); + // Ids given: only those go, the guides stay. + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + s.execute("select.add", &json!({"ids": [b.0]})).unwrap(); + s.execute("edit.clear", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(guides(&s).len(), 2); +} + /// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved, /// copied and deleted with it; canvas guides stay put. #[test] From d93e4e9cf15e79f2b563d8230a2cb033e0e367d9 Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Sat, 10 Oct 2026 18:30:41 +0000 Subject: [PATCH 49/97] Docs: Shift-selecting guides with art in the parity doc Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4d1d1a534..dbab50eec 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -412,7 +412,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel. - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). -- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. +- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. From a2530621fcb64f2c2b83dca48dc881f91fb3c84d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 04:19:23 +0700 Subject: [PATCH 50/97] ROADMAP: guides and art selected together (#1022) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 510f88f59..1377dde6e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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 | From 0abb4157c9e9d00c21e05a531f509ff53f86b5d1 Mon Sep 17 00:00:00 2001 From: mateususa <150644469+mateususa@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:24:03 -0700 Subject: [PATCH 51/97] #1026: open filled paths are hit by their fill on every side fill_contains promised to close open subpaths (as their fill does) but counted kurbo's winding, which leaves the closing edge out: a point beside it read as outside. An open path (an anchor deleted from a closed one) lost its fill clicks on that side, and a reflection moved the edge across, so it looked like Reflect broke selection. Open subpaths are now closed by a straight line back to their start before the winding is counted; closed paths are borrowed as they are. Tests: the open path from the report, on both sides and even-odd; reflected shapes, imported SVG hearts, a selected shape dragged by its fill. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_reflecthit.rs | 153 ++++++++++++++++++++++++++ crates/geom/src/hit.rs | 72 +++++++++++- 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/tests_reflecthit.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5c54128c3..95f0ad8f9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1629,6 +1629,8 @@ mod tests_recolor; #[cfg(test)] mod tests_recovery; #[cfg(test)] +mod tests_reflecthit; +#[cfg(test)] mod tests_registration; #[cfg(test)] mod tests_save; diff --git a/crates/engine/src/tests_reflecthit.rs b/crates/engine/src/tests_reflecthit.rs new file mode 100644 index 000000000..89d1c3272 --- /dev/null +++ b/crates/engine/src/tests_reflecthit.rs @@ -0,0 +1,153 @@ +//! A reflected object is still clicked by its fill, not only by its outline. + +use serde_json::{Value, json}; +use vectorcraft_geom::Point; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A click with the Selection tool at (x, y): what it selects. +fn click(s: &mut Session, x: f64, y: f64) -> Vec { + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), v).unwrap(); + } + s.doc().unwrap().selection.objects.clone() +} + +#[test] +fn a_reflected_shape_is_clicked_by_its_fill() { + const HEART: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z"; + let makers: [(&str, Value); 5] = [ + ("shape.rectangle", json!({"x": 150, "y": 150, "width": 100, "height": 100})), + ("shape.ellipse", json!({"x": 150, "y": 150, "width": 100, "height": 100})), + ("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 60, "sides": 5})), + ("shape.star", json!({"cx": 200, "cy": 200, "radius1": 70, "radius2": 40, "points": 5})), + ("path.create", json!({"d": HEART})), + ]; + for (cmd, params) in makers { + for axis in ["horizontal", "vertical"] { + let mut s = session(); + let id = id_of(&s.execute(cmd, ¶ms).unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.reflect", &json!({"axis": axis})).unwrap(); + let b = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + let c = b.center(); + assert_eq!(click(&mut s, c.x, c.y), vec![id], "{cmd} reflected {axis}: a click in its fill at {c:?} (bounds {b:?})"); + } + } +} + +/// Hearts as SVG files bring them: a plain path, a compound path, inside a group, under a +/// transform. Reflected with the button and with a bounding-box handle dragged across. +#[test] +fn a_reflected_imported_heart_is_clicked_by_its_fill() { + const D: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z"; + let files = [ + format!(r##""##), + format!( + r##""## + ), + format!(r##""##), + format!( + r##""## + ), + format!( + r##""## + ), + ]; + for (i, svg) in files.iter().enumerate() { + for how in ["horizontal", "vertical", "handle"] { + let mut s = Session::new(); + s.add_document(vectorcraft_svg::import(svg).unwrap(), None); + s.execute("select.all", &json!({})).unwrap(); + let ids = s.doc().unwrap().selection.objects.clone(); + assert!(!ids.is_empty(), "file {i}: nothing imported"); + let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap(); + if how == "handle" { + // Drag the top handle down past the bottom: a top-to-bottom flip. + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let (from, to) = (Point::new(b.center().x, b.y0), Point::new(b.center().x, b.y1 + b.height())); + for (kind, p) in + [(PointerKind::Down, from), (PointerKind::Drag, Point::new(from.x, b.center().y)), (PointerKind::Drag, to), (PointerKind::Up, to)] + { + s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap(); + } + } else { + s.execute("object.reflect", &json!({"axis": how})).unwrap(); + } + let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + // Inside the heart away from the compound's square hole: a third of the way down. + let c = Point::new(b.center().x, if how == "horizontal" || how == "handle" { b.y1 - b.height() * 0.3 } else { b.y0 + b.height() * 0.3 }); + let picked = click(&mut s, c.x, c.y); + assert_eq!(picked, ids, "file {i} flipped by {how}: a click in its fill at {c:?} (bounds {b:?})"); + } + } +} + +/// Still selected after the flip, a drag from inside the fill moves it. +#[test] +fn a_reflected_selected_shape_drags_by_its_fill() { + const HEART: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z"; + let makers: [(&str, Value); 4] = [ + ("shape.rectangle", json!({"x": 150, "y": 150, "width": 100, "height": 100})), + ("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 60, "sides": 3})), + ("shape.star", json!({"cx": 200, "cy": 200, "radius1": 70, "radius2": 40, "points": 5})), + ("path.create", json!({"d": HEART})), + ]; + for (cmd, params) in makers { + for axis in ["horizontal", "vertical"] { + let mut s = session(); + let id = id_of(&s.execute(cmd, ¶ms).unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.reflect", &json!({"axis": axis})).unwrap(); + let b = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + let c = b.center(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + for (kind, x) in + [(PointerKind::Down, c.x), (PointerKind::Drag, c.x + 20.0), (PointerKind::Drag, c.x + 40.0), (PointerKind::Up, c.x + 40.0)] + { + s.pointer(&PointerEvent::new(kind, x, c.y).with_mods(Mods::default()), v).unwrap(); + } + let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + assert!((after.x0 - b.x0 - 40.0).abs() < 1e-6, "{cmd} reflected {axis}: a drag from {c:?} left it at {after:?} (was {b:?})"); + } + } +} + +/// An open path (an anchor deleted from a closed one) still fills as if closed, and its fill is +/// clicked on both sides of that closing line, reflected or not. +#[test] +fn an_open_filled_path_is_clicked_by_its_fill_either_way_round() { + // The sliver of a strawberry, open, its closing line down its left side. + let svg = r##""##; + for axis in [None, Some("vertical"), Some("horizontal")] { + let mut s = Session::new(); + s.add_document(vectorcraft_svg::import(svg).unwrap(), None); + s.execute("select.all", &json!({})).unwrap(); + let ids = s.doc().unwrap().selection.objects.clone(); + if let Some(axis) = axis { + s.execute("object.reflect", &json!({"axis": axis})).unwrap(); + } + let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + // The middle of the sliver's widest part, well inside it. + let x = if axis == Some("vertical") { b.x0 + b.width() * 0.4 } else { b.x0 + b.width() * 0.6 }; + assert_eq!(click(&mut s, x, b.center().y), ids, "reflected {axis:?}: a click in the fill (bounds {b:?})"); + } +} diff --git a/crates/geom/src/hit.rs b/crates/geom/src/hit.rs index 6115a96c4..6e4664a98 100644 --- a/crates/geom/src/hit.rs +++ b/crates/geom/src/hit.rs @@ -1,18 +1,60 @@ //! Hit testing primitives. -use kurbo::{BezPath, ParamCurveNearest, Point, Rect, Shape}; +use std::borrow::Cow; + +use kurbo::{BezPath, ParamCurveNearest, PathEl, Point, Rect, Shape}; use crate::path::{FillRule, PathData}; /// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled). pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool { - let w = path.winding(p); + let w = closed(path).winding(p); match rule { FillRule::NonZero => w != 0, FillRule::EvenOdd => w % 2 != 0, } } +/// `path` with each open subpath closed by a straight line back to its start, the edge its fill +/// has. kurbo's winding leaves that edge out, so a point beside it counted as outside the fill: +/// an open path lost its fill clicks on that side (a reflection moved the edge to the other one). +fn closed(path: &BezPath) -> Cow<'_, BezPath> { + let mut open = false; + let mut needs = false; + for el in path.elements() { + match el { + PathEl::MoveTo(_) => { + needs |= open; + open = true; + } + PathEl::ClosePath => open = false, + _ => {} + } + } + if !(needs || open) { + return Cow::Borrowed(path); + } + let mut out = BezPath::new(); + let mut open = false; + for &el in path.elements() { + match el { + PathEl::MoveTo(_) => { + if open { + out.close_path(); + } + open = true; + } + PathEl::ClosePath => open = false, + _ => {} + } + out.push(el); + } + if open { + out.close_path(); + } + Cow::Owned(out) +} + /// Distance from `p` to the nearest point on the path outline. pub fn distance_to_outline(path: &BezPath, p: Point) -> f64 { path.segments().map(|s| s.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt() @@ -77,6 +119,32 @@ mod tests { assert!(fill_contains(&bp, FillRule::EvenOdd, Point::new(1.0, 5.0))); } + /// An open path fills as if closed by a straight line back to its start, and is hit there, + /// whichever side that line falls on (a reflected open path lost its fill clicks). + #[test] + fn open_paths_fill_as_closed_on_either_side() { + // A filled sliver left open by a deleted anchor; its closing line runs down its left side. + let open = BezPath::from_svg( + "M86.311 69.612C100.544 78.261 110.506 88.64 110.506 100.748C110.506 111.127 100.544 118.046 86.311 118.046C93.214 102.045 91.407 102.403 91.407 97.61", + ) + .unwrap(); + let mut closed = open.clone(); + closed.close_path(); + for p in [Point::new(100.0, 100.0), Point::new(95.0, 85.0), Point::new(92.0, 112.0)] { + assert!(closed.winding(p) != 0, "{p:?} is inside the closed sliver"); + assert!(fill_contains(&open, FillRule::NonZero, p), "{p:?}: the open sliver's fill"); + assert!(fill_contains(&open, FillRule::EvenOdd, p), "{p:?}: even-odd too"); + } + assert!(!fill_contains(&open, FillRule::NonZero, Point::new(80.0, 100.0)), "left of the closing line"); + // Mirrored, the closing line is on the other side: still filled. + let flipped = kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, 200.0, 0.0]) * open.clone(); + assert!(fill_contains(&flipped, FillRule::NonZero, Point::new(100.0, 100.0))); + // Two open subpaths: each closes on its own. + let mut two = open.clone(); + two.extend(kurbo::Affine::translate((0.0, 100.0)) * open.clone()); + assert!(fill_contains(&two, FillRule::NonZero, Point::new(100.0, 100.0)) && fill_contains(&two, FillRule::NonZero, Point::new(100.0, 200.0))); + } + #[test] fn marquee() { let p = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)); From 0b0ccfb35a21f35c9042592579c00baf81ec11fa Mon Sep 17 00:00:00 2001 From: mateususa <150644469+mateususa@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:24:08 -0700 Subject: [PATCH 52/97] #1026: the rotate cursor wraps the corner it is at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beside every corner of the bounding box the curved rotate cursor bowed up. It now bows from the box's centre toward the pointer, in 45° steps (Cursor::RotateToward, made by Cursor::rotate_about): at the top right it opens toward the bottom left, at the bottom right toward the top left. The Selection tool shows it over the rotate zone and while rotating (it follows the pointer round), and Free Transform does the same. Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/lib.rs | 17 +++++++++++++++++ crates/tools/src/select.rs | 22 ++++++++++++++++++++-- crates/tools/src/xform/free.rs | 2 +- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/cursors.rs | 18 ++++++++++++------ 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 4febf31d9..c15cce789 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -425,6 +425,11 @@ pub enum Cursor { ResizeNwSe, ResizeNeSw, Rotate, + /// The rotate cursor beside a bounding box, bowed toward the pointer's side of the box: off + /// its centre in direction `0..8` (0 straight up, then clockwise in 45° steps). Its arc wraps + /// the corner it is at, so at the top right it opens toward the bottom left. Make it with + /// [`Cursor::rotate_about`]. + RotateToward(u8), /// Over a Live Corners widget (drag to round the corners). CornerRadius, Pen, @@ -476,6 +481,18 @@ pub enum Cursor { } impl Cursor { + /// The rotate cursor for the pointer at `p` turning art about `center`: [`Cursor::RotateToward`] + /// the pointer's side of it, or the plain [`Cursor::Rotate`] on the centre itself. + pub fn rotate_about(center: Point, p: Point) -> Self { + let d = p - center; + if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { + return Cursor::Rotate; + } + // y points down the page: atan2(x, -y) is 0 straight up and grows clockwise. + let octant = (d.x.atan2(-d.y) / std::f64::consts::FRAC_PI_4).round() as i64; + Cursor::RotateToward(octant.rem_euclid(8) as u8) + } + /// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the /// Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot. pub fn precise(self) -> Self { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 4a3a4dca2..21934d78b 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -444,7 +444,7 @@ impl Tool for SelectionTool { fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { match self.state { - State::Rotating { .. } => return Cursor::Rotate, + State::Rotating { center, .. } => return Cursor::rotate_about(center, p), State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle), State::Moving { began: true, .. } => return Cursor::Arrow, State::Corner(_) => return Cursor::CornerRadius, @@ -468,7 +468,7 @@ impl Tool for SelectionTool { { match box_hit(cx, &bx, p) { Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle), - Some(BoxHit::Rotate) => return Cursor::Rotate, + Some(BoxHit::Rotate) => return Cursor::rotate_about(bx.center(), p), None => {} } } @@ -588,6 +588,24 @@ mod tests { assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 160.0, 150.0)).is_empty()); } + /// Just outside each corner of the box the rotate cursor bows toward that corner, its arc + /// opening toward the box: at the top right it opens to the bottom left. + #[test] + fn the_rotate_cursor_wraps_the_corner_it_is_at() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let t = SelectionTool::default(); + // The box runs from (100, 100) to (200, 200); 10 pt out from each corner, diagonally. + for ((x, y), octant) in [((93.0, 93.0), 7), ((207.0, 93.0), 1), ((207.0, 207.0), 3), ((93.0, 207.0), 5)] { + assert_eq!(t.cursor(&cx, Point::new(x, y), Mods::default()), Cursor::RotateToward(octant), "at ({x}, {y})"); + } + assert_eq!(Cursor::rotate_about(Point::new(1.0, 1.0), Point::new(1.0, 1.0)), Cursor::Rotate, "on the centre"); + assert_eq!(Cursor::rotate_about(Point::ORIGIN, Point::new(0.0, -5.0)), Cursor::RotateToward(0), "straight up"); + } + #[test] fn drag_moves_with_single_undo() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 73d631f00..54099c817 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -313,7 +313,7 @@ impl Tool for FreeTransformTool { fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { let Some(r) = selection_bounds(cx) else { return Cursor::Arrow }; match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) { - Some(Op::Rotate) => Cursor::Rotate, + Some(Op::Rotate) => Cursor::rotate_about(r.center(), p), Some(Op::Move) => Cursor::Move, Some(Op::Scale(h) | Op::Shear(h)) => match h { Handle::Top | Handle::Bottom => Cursor::ResizeV, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8cfcfe589..16fc26a5c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -487,7 +487,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ResizeV => C::ResizeVertical, Cursor::ResizeNwSe => C::ResizeNwSe, Cursor::ResizeNeSw => C::ResizeNeSw, - Cursor::Rotate => C::Alias, + Cursor::Rotate | Cursor::RotateToward(_) => C::Alias, Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenJoin | Cursor::PenConvert => { C::Crosshair } diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index d98a2d2e8..f273af20b 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -113,17 +113,21 @@ fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) { } /// A curved double arrow over the hotspot: an arc with an arrowhead at each end, pointing on along -/// the arc (#701). -fn rotate(p: &mut Ink, o: Pos2) { +/// the arc (#701). The arc bows up, or toward `octant` (0 up, then clockwise in 45° steps): beside a +/// bounding box it wraps the corner the pointer is at. +fn rotate(p: &mut Ink, o: Pos2, octant: u8) { const R: f32 = 8.0; let (a0, a1) = (std::f32::consts::PI * 0.12, std::f32::consts::PI * 0.88); - let at = |a: f32| o + vec2(a.cos() * R, -a.sin() * R); + // Clockwise on screen (y down) by whole octants. + let (s, c) = (f32::from(octant % 8) * std::f32::consts::FRAC_PI_4).sin_cos(); + let turn = |v: Vec2| vec2(v.x * c - v.y * s, v.x * s + v.y * c); + let at = |a: f32| o + turn(vec2(a.cos() * R, -a.sin() * R)); let pts: Vec = (0..=12).map(|i| at(a0 + (a1 - a0) * i as f32 / 12.0)).collect(); p.add(Shape::line(pts.clone(), Stroke::new(3.2, HALO))); p.add(Shape::line(pts, Stroke::new(1.3, INK))); // Each head points away from the arc, along its tangent there. for (a, away) in [(a0, -1.0f32), (a1, 1.0)] { - let t = vec2(-a.sin(), -a.cos()) * away; + let t = turn(vec2(-a.sin(), -a.cos()) * away); let n = vec2(-t.y, t.x); let (end, tip) = (at(a) - t * 1.5, at(a) + t * 4.5); poly(p, vec![tip, end + n * 1.8, end - n * 1.8], INK, INK); @@ -269,7 +273,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)), Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)), Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)), - Cursor::Rotate => rotate(ink, p), + Cursor::Rotate => rotate(ink, p, 0), + Cursor::RotateToward(octant) => rotate(ink, p, octant), Cursor::CornerRadius => corner_radius(ink, p), Cursor::Pen => pen(ink, p, "*"), Cursor::PenAdd => pen(ink, p, "+"), @@ -432,7 +437,7 @@ mod tests { use crate::VectorcraftApp; /// Every cursor with a glyph. - const GLYPHS: [Cursor; 33] = [ + const GLYPHS: [Cursor; 34] = [ Cursor::Arrow, Cursor::ArrowHollow, Cursor::Move, @@ -442,6 +447,7 @@ mod tests { Cursor::ResizeNwSe, Cursor::ResizeNeSw, Cursor::Rotate, + Cursor::RotateToward(1), Cursor::CornerRadius, Cursor::Pen, Cursor::PenAdd, From 0eb1e463b9ad24c6efee1c6f5f53ca3dc49a0051 Mon Sep 17 00:00:00 2001 From: mateususa <150644469+mateususa@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:24:19 -0700 Subject: [PATCH 53/97] #1026: the flip buttons' icons match their flips Lucide names its flip icons for their mirror line: flip-horizontal-2 draws a horizontal line with triangles above and below (the top-bottom flip). The Properties and Transform panels showed it on the left-right flip, and the other on the top-bottom one. Swapped back, with a comment. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 6 ++++-- crates/ui-egui/src/panels/transform.rs | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index b48a4a748..7a6de4c31 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -442,10 +442,12 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("object.rotate", json!({"angle": a, "absolute": true})).ok(); } ui.add_space(10.0); - if widgets::icon_button(ui, "flip-horizontal-2", tl!("Flip Along Horizontal Axis"), false, 24.0).clicked() { + // Lucide's flip icons are named for their mirror line: flip-vertical-2 draws a vertical + // line (the left-right flip), flip-horizontal-2 a horizontal one (top-bottom). + if widgets::icon_button(ui, "flip-vertical-2", tl!("Flip Along Horizontal Axis"), false, 24.0).clicked() { app.run("object.reflect", json!({"axis": "vertical"})).ok(); } - if widgets::icon_button(ui, "flip-vertical-2", tl!("Flip Along Vertical Axis"), false, 24.0).clicked() { + if widgets::icon_button(ui, "flip-horizontal-2", tl!("Flip Along Vertical Axis"), false, 24.0).clicked() { app.run("object.reflect", json!({"axis": "horizontal"})).ok(); } }); diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index a531f2c8d..949da2acb 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -167,10 +167,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); }); ui.horizontal(|ui| { - if widgets::icon_button_enabled(ui, "flip-horizontal-2", tl!("Flip Horizontal"), false, has, 24.0).clicked() { + // Lucide's flip icons are named for their mirror line: flip-vertical-2 draws a vertical + // line (the left-right flip), flip-horizontal-2 a horizontal one (top-bottom). + if widgets::icon_button_enabled(ui, "flip-vertical-2", tl!("Flip Horizontal"), false, has, 24.0).clicked() { app.run("object.reflect", json!({"axis": "vertical", "origin": origin})).ok(); } - if widgets::icon_button_enabled(ui, "flip-vertical-2", tl!("Flip Vertical"), false, has, 24.0).clicked() { + if widgets::icon_button_enabled(ui, "flip-horizontal-2", tl!("Flip Vertical"), false, has, 24.0).clicked() { app.run("object.reflect", json!({"axis": "horizontal", "origin": origin})).ok(); } }); From 7abce8426a8aa073c472bdee93ae94b0b224bf06 Mon Sep 17 00:00:00 2001 From: mateususa <150644469+mateususa@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:24:27 -0700 Subject: [PATCH 54/97] #1026: the Color Picker shows the colour on the selection live With art selected the picker changed it only on OK. Each new colour now previews paint.setFill / paint.setStroke on the selection through the session interaction (the spec is preview: true): OK keeps the last one as one undo step, Cancel puts the art back. Nothing previews until the colour first changes, nor without a selection (the colour then only becomes the default paint, on OK). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/color_picker.rs | 84 +++++++++++++++++++--- 1 file changed, 73 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index 11ed35716..473388342 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -1,7 +1,8 @@ //! The Color Picker (double-click a Fill/Stroke proxy, or `ui.colorPicker`): a colour field with a //! channel slider for H, S, B, R, G or B; HSB, RGB, Lab and CMYK fields and hex; Only Web Colors; //! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; and a Color -//! Swatches list. OK applies the colour to its proxy through `paint.setFill` / `paint.setStroke`. +//! Swatches list. With art selected the colour shows on it live as it is picked; OK keeps it as one +//! undo step through `paint.setFill` / `paint.setStroke`, Cancel puts the art back. //! //! Fields agents set with `ui.dialog.set`: `hex` (`"00FF00"`) or `color` (any colour the paint //! commands take), `channel` (`hue`, `saturation`, `brightness`, `red`, `green`, `blue`), `webOnly` @@ -15,6 +16,7 @@ use vectorcraft_doc::ColorMode; use vectorcraft_engine::cmd::color_value; use super::DialogSpec; +use super::form::PREVIEWED; use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe}; use crate::panels::{c32, color_json}; use crate::state::Dialog; @@ -23,8 +25,15 @@ use crate::{VectorcraftApp, widgets}; pub(super) const KIND: &str = "colorPicker"; -pub(super) const SPEC: DialogSpec = - DialogSpec { heading: |_| tl!("Color Picker").into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Color Picker").into(), + body, + confirm, + preview: true, + min_width: 600.0, + max_width: Some(640.0), + ..DialogSpec::FORM +}; /// Side of the colour field (and height of the channel slider). const FIELD: f32 = 240.0; @@ -242,9 +251,39 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { d.fields.insert("swatches".into(), json!(!swatches)); } store(d, &pick.unwrap_or(p).snapped(d.bool("webOnly"))); + preview(app, d); false } +/// Show the picked colour on the selected art while the picker is open: each new colour replaces +/// the last preview. Nothing previews until the colour first changes, nor without a selection +/// (the colour then only becomes the default paint, on OK). +fn preview(app: &mut VectorcraftApp, d: &mut Dialog) { + let (cmd, params) = paint_command(app, d); + let first = !app.session.in_interaction(); + let unchanged = d.fields.get(PREVIEWED) == Some(¶ms) || first && d.fields.get("original").and_then(color_value) == Some(picked(d).color); + let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()); + if unchanged || !selected { + return; + } + let _ = app.session.begin_interaction(if d.bool("stroke") { "Stroke Color" } else { "Fill Color" }); + if let Err(e) = app.session.preview(cmd, ¶ms) { + app.status(e.to_string()); + } + d.fields.insert(PREVIEWED.into(), params); +} + +/// The command that applies the picked colour to the proxy, and its params (as CMYK in a CMYK +/// document). +fn paint_command(app: &VectorcraftApp, d: &Dialog) -> (&'static str, Value) { + let mut c = picked(d).color; + if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) { + let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent); + c = Color::cmyk(cy, m, y, k); + } + (if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": color_json(&c) })) +} + /// The New (top) and Original (bottom) chips. Returns true when Original is clicked. fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool { let t = Tokens::get(ui.ctx()); @@ -363,15 +402,10 @@ fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color) -> Option Result { - let mut c = picked(d).color; - if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) { - let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent); - c = Color::cmyk(cy, m, y, k); - } - let cmd = if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" }; - super::run_and_close(app, cmd, json!({ "color": color_json(&c) })) + let (cmd, params) = paint_command(app, d); + super::form::commit_preview(app, cmd, params) } #[cfg(test)] @@ -453,6 +487,34 @@ mod tests { assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None); } + /// The selected art takes each colour as it is picked; OK keeps the last as one undo step and + /// Cancel puts the art back. + #[test] + fn the_selection_shows_the_colour_live() { + let mut app = app(); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let before = undo(&app); + app.run("ui.colorPicker", json!({})).unwrap(); + frame(&mut app); + assert!(!app.session.in_interaction(), "nothing previews before the colour changes"); + for hex in ["ff0000", "00ff00"] { + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex)); + frame(&mut app); + assert_eq!(fill_hex(&app), format!("#{hex}"), "live, before OK"); + } + super::super::confirm(&mut app).unwrap(); + assert_eq!(fill_hex(&app), "#00ff00"); + assert_eq!(undo(&app), before + 1, "one step"); + // Cancel: back to the colour it had. + app.run("ui.colorPicker", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff")); + frame(&mut app); + assert_eq!(fill_hex(&app), "#0000ff"); + super::super::cancel(&mut app); + assert_eq!(fill_hex(&app), "#00ff00"); + assert_eq!(undo(&app), before + 1); + } + #[test] fn open_set_hex_and_confirm_fills() { let mut app = app(); From 580ada65d59236345f264dbbf7434e37a79ba895 Mon Sep 17 00:00:00 2001 From: mateususa <150644469+mateususa@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:24:28 -0700 Subject: [PATCH 55/97] =?UTF-8?q?#1026:=20Properties=20=E2=80=BA=20Transfo?= =?UTF-8?q?rm=20reaches=20Scale=20Corners=20and=20Scale=20Strokes=20&=20Ef?= =?UTF-8?q?fects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Transform panel has both options; the Properties panel's Transform section had no way to them. A More Options button after the flip buttons now opens a popup with the two (the scaleCorners / scaleStrokes prefs), open while they are toggled. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 7a6de4c31..ccd451ce8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -450,6 +450,18 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::icon_button(ui, "flip-horizontal-2", tl!("Flip Along Vertical Axis"), false, 24.0).clicked() { app.run("object.reflect", json!({"axis": "horizontal"})).ok(); } + // More Options: the Transform panel's Scale Corners and Scale Strokes & Effects, open + // while they are toggled. + let more = widgets::icon_button(ui, "ellipsis", tl!("More Options"), false, 24.0); + egui::Popup::menu(&more).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes); + if widgets::check(ui, tl!("Scale Corners"), sc, true) { + super::transform::set_pref(app, "scaleCorners", !sc); + } + if widgets::check(ui, tl!("Scale Strokes & Effects"), ss, true) { + super::transform::set_pref(app, "scaleStrokes", !ss); + } + }); }); // Live shape properties. if let Some(n) = first_selected(app) @@ -625,6 +637,44 @@ mod tests { assert!(widths.iter().all(|w| (w - widths[0]).abs() < 1.0), "X, rotation, corner radius, opacity: {widths:?}"); } + /// The Transform section's More Options holds Scale Corners and Scale Strokes & Effects, and + /// stays open while they are toggled. + #[test] + fn transform_more_options_toggles_scale_corners_and_strokes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); + let ctx = egui::Context::default(); + let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); + frame(&mut app, &ctx, vec![]); + let texts = frame(&mut app, &ctx, vec![]); + assert!(!has(&texts, "Scale Corners"), "closed at first"); + // The button is an icon at the end of the angle row, under the H: field: sweep that row. + let h = texts.iter().find(|(t, _)| t == "H:").unwrap().1; + let mut open = None; + 'sweep: for dy in (12..60).step_by(6) { + for x in (120..300).step_by(6) { + let at = egui::pos2(x as f32, h.bottom() + dy as f32); + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(at), b(true)]); + frame(&mut app, &ctx, vec![b(false)]); + let texts = frame(&mut app, &ctx, vec![]); + if has(&texts, "Scale Corners") { + open = Some(texts); + break 'sweep; + } + } + } + let texts = open.expect("More Options opens the popup"); + let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes); + click(&mut app, &ctx, &texts, "Scale Corners"); + assert_eq!(app.session.prefs.scale_corners, !sc); + let texts = frame(&mut app, &ctx, vec![]); + assert!(has(&texts, "Scale Strokes & Effects"), "still open after a toggle"); + click(&mut app, &ctx, &texts, "Scale Strokes & Effects"); + assert_eq!(app.session.prefs.scale_strokes, !ss); + } + /// Both panels forward the Move Artwork toggle when their actual X field is edited. #[test] fn artboard_coordinate_fields_move_art_in_properties_and_transform() { From 1b3cf83e04f2cb7e9b2a23374a7a50fa0899cb20 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 04:24:34 +0700 Subject: [PATCH 56/97] ROADMAP: the #1036 fixes in the progress log Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1377dde6e..e641fc2fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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 | From 1622a70f85d0f0981362b4682b66433e77da7526 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sat, 10 Oct 2026 22:25:14 +0200 Subject: [PATCH 57/97] #1028: Offset Path finishes where the sweep looped forever The sweep under Offset Path (linesweeper 0.4) never finished on some miter offsets: on the reported glyph and on a synthetic outline it kept allocating until the app ran out of memory. linesweeper 0.5 fixes that infinite loop ("Fixed an infinite loop", 0.5.0 changelog), and builds without changes. 0.5 can hand back a curve piece that doesn't follow the input curve it was cut from. In the Shape Builder rosette test, a contour runs out along one petal and back by such a piece, which became a 59 x 4 pt hole in the merged shape. Tidy::repair rebuilds a piece that strays from the one input curve through both its ends as that curve's exact sub-curve (input curves are indexed by their bounds' left edge, so it stays cheap). The there-and- back then encloses nothing and is dropped as a sliver. Pieces on their curve, and pieces whose ends two input curves pass through (a real lens), are left alone. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 +- Cargo.toml | 2 +- crates/pathops/src/boolean.rs | 147 +++++++++++++++++++++++++++++++++- crates/pathops/src/offset.rs | 25 ++++++ 4 files changed, 171 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70910ff1c..ce87c92c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2167,9 +2167,9 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" [[package]] name = "linesweeper" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769" +checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18" dependencies = [ "arrayvec", "kurbo 0.13.1", diff --git a/Cargo.toml b/Cargo.toml index 975b0e8ed..ae4d113a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,7 @@ usvg = "0.48" krilla = "0.8" skrifa = "0.47" harfrust = "0.13" -linesweeper = "0.4" +linesweeper = "0.5" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } # Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art. ruzstd = { version = "0.9", default-features = false, features = ["std"] } diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 677424c77..7108f770a 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -235,12 +235,16 @@ pub(crate) struct Tidy { keep: Vec, /// Input curves (with inflated bounds) used to rebuild split pieces exactly. curves: Vec<(kurbo::Rect, CubicBez)>, + /// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose + /// bounds can hold a point, without testing them all. + by_x: Vec, + max_width: f64, } impl Tidy { /// Merge any smooth joint. pub(crate) fn free(precision: f64) -> Self { - Self { precision, keep: Vec::new(), curves: Vec::new() } + Self { precision, ..Default::default() } } /// Keep every on-curve point of `paths`. pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator) -> Self { @@ -257,7 +261,10 @@ impl Tidy { } } keep.sort_by(|a, b| a.x.total_cmp(&b.x)); - Self { precision, keep, curves } + let mut by_x: Vec = (0..curves.len()).collect(); + by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0)); + let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max); + Self { precision, keep, curves, by_x, max_width } } fn is_kept(&self, p: Point) -> bool { let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs())); @@ -322,10 +329,11 @@ impl Arrangement { pub(crate) fn contours_to_path(c: &Contours, idx: impl IntoIterator, tidy: &Tidy) -> PathData { let mut subpaths = Vec::new(); for i in idx { - if is_sliver(&c[i].path, tidy.precision) { + let path = tidy.repair(&c[i].path); + if is_sliver(&path, tidy.precision) { continue; } - if let Some(sp) = tidy_bezpath(&c[i].path, tidy) { + if let Some(sp) = tidy_bezpath(&path, tidy) { subpaths.push(sp); } } @@ -515,6 +523,84 @@ fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option { None } +impl Tidy { + /// The input curves whose bounds hold both `a` and `b`. + fn curves_around(&self, a: Point, b: Point) -> impl Iterator { + let x = a.x.min(b.x); + let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x); + let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width); + self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c) + } + + /// `bp` with each curve piece that strays from the one input curve through both its ends + /// rebuilt as that curve's exact sub-curve. linesweeper 0.5 can emit a piece that doesn't + /// follow its curve; out and back between two points of one curve, such pieces are a lens + /// instead of nothing (issue #1028). A piece on a curve, or whose ends two different input + /// curves pass through (a real lens between them), is left as it is. + pub(crate) fn repair(&self, bp: &BezPath) -> BezPath { + use kurbo::{ParamCurve, ParamCurveNearest, PathEl}; + if self.curves.is_empty() { + return bp.clone(); + } + let mut out = BezPath::new(); + let mut cur = Point::ZERO; + let mut start = Point::ZERO; + for el in bp.iter() { + let piece = match el { + PathEl::QuadTo(c, p) => Some(kurbo::QuadBez::new(cur, c, p).raise()), + PathEl::CurveTo(c1, c2, p) => Some(CubicBez::new(cur, c1, c2, p)), + _ => None, + }; + match (el, piece) { + (_, Some(raw)) => { + let (a, b) = (raw.p0, raw.p3); + let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs()); + let tol = 1e-6 * scale; + let mut subs: Vec = vec![]; + let mut follows = false; + for c in self.curves_around(a, b) { + let (na, nb) = (c.nearest(a, 1e-12), c.nearest(b, 1e-12)); + if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol || (na.t - nb.t).abs() < 1e-12 { + continue; + } + if [0.25, 0.5, 0.75].iter().all(|&t| c.nearest(raw.eval(t), 1e-12).distance_sq.sqrt() <= tol * 10.0) { + follows = true; + break; + } + let sub = if na.t < nb.t { + c.subsegment(na.t..nb.t) + } else { + let r = c.subsegment(nb.t..na.t); + CubicBez::new(r.p3, r.p2, r.p1, r.p0) + }; + subs.push(CubicBez::new(a, sub.p1, sub.p2, b)); + } + let same = |p: &CubicBez, q: &CubicBez| p.p1.distance(q.p1) <= tol * 10.0 && p.p2.distance(q.p2) <= tol * 10.0; + match subs.split_first() { + Some((sub, rest)) if !follows && rest.iter().all(|q| same(sub, q)) => out.curve_to(sub.p1, sub.p2, b), + _ => out.push(el), + } + cur = b; + } + (PathEl::MoveTo(p), None) => { + out.push(el); + (cur, start) = (p, p); + } + (PathEl::LineTo(p), None) => { + out.push(el); + cur = p; + } + (PathEl::ClosePath, None) => { + out.push(el); + cur = start; + } + (_, None) => out.push(el), + } + } + out + } +} + /// Resolve self-intersections / overlaps of a single filled path into simple, consistently /// oriented contours (outer contours and holes). pub fn normalize(path: &PathData, rule: FillRule) -> PathData { @@ -592,3 +678,56 @@ pub fn area(path: &PathData, rule: FillRule) -> f64 { Err(_) => 0.0, } } + +#[cfg(test)] +mod tests { + use kurbo::ParamCurve; + + use super::*; + + /// A quarter-circle-like arc, the input curve the pieces below are cut from. + fn arc() -> CubicBez { + CubicBez::new((0.0, 0.0), (0.0, 55.0), (45.0, 100.0), (100.0, 100.0)) + } + + #[test] + fn a_stray_piece_out_and_back_along_one_curve_is_no_outline() { + // Issue #1028: linesweeper 0.5 handed back a contour running out along an input curve and + // back by a piece that doesn't follow it, which refit into a visible lens. + let c = arc(); + let tidy = Tidy::keeping(DEFAULT_PRECISION, [&BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)])]); + let out = c.subsegment(0.2..0.6); + let mut contour = BezPath::new(); + contour.move_to(out.p0); + contour.curve_to(out.p1, out.p2, out.p3); + // back to the start, bulging off the curve and overshooting the end like the reported piece + contour.curve_to(out.p0 + (out.p0 - out.p3) * 0.15 + kurbo::Vec2::new(4.0, -3.0), out.p1 + kurbo::Vec2::new(3.0, -2.0), out.p0); + contour.close_path(); + assert!(!is_sliver(&contour, tidy.precision), "the stray piece makes a lens"); + let fixed = tidy.repair(&contour); + assert!(is_sliver(&fixed, tidy.precision), "{}", fixed.to_svg()); + // a piece on its curve is left exactly as it is + let mut on = BezPath::new(); + on.move_to(out.p0); + on.curve_to(out.p1, out.p2, out.p3); + on.line_to(out.p0); + on.close_path(); + assert_eq!(tidy.repair(&on), on); + } + + #[test] + fn a_lens_between_two_curves_through_the_same_points_stays() { + // Two input curves meeting at both ends bound a real face: neither piece is "repaired" + // onto the other. + let lower = CubicBez::new((0.0, 0.0), (30.0, 20.0), (70.0, 20.0), (100.0, 0.0)); + let upper = CubicBez::new((0.0, 0.0), (30.0, -20.0), (70.0, -20.0), (100.0, 0.0)); + let input = |c: CubicBez| BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)]); + let tidy = Tidy::keeping(DEFAULT_PRECISION, [&input(lower), &input(upper)]); + let mut lens = BezPath::new(); + lens.move_to(lower.p0); + lens.curve_to(lower.p1, lower.p2, lower.p3); + lens.curve_to(upper.p2, upper.p1, upper.p0); + lens.close_path(); + assert_eq!(tidy.repair(&lens), lens); + } +} diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index 623ffbbb1..548546d8d 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -192,4 +192,29 @@ mod tests { // Ring 16·20 − 8·12 = 224, plus the bar outside it (10·4) and inside its hole (6·4). assert!((area - (224.0 + 40.0 + 24.0)).abs() < 1e-6, "{area}"); } + + #[test] + fn miter_offset_finishes_where_the_sweep_used_to_loop() { + // Issue #1028: with linesweeper 0.4 the sweep over this miter offset never finished and + // its memory grew without bound, freezing the app. A synthetic outline (found by search) + // with the same failure as the reported glyph; the exact coordinates matter. + const OUTLINE: &str = "M74.359137297823,82.83738194552835 L88.097432485874,103.04979803227248 \ + L67.06936731196308,105.95779886823588 L53.29382873396223,98.53943925153727 \ + L29.684713319694094,94.17996020306241 L41.65480824807079,75.639672165115 \ + Q47.491034578751446,52.12555333014565 46.157302824282816,55.67193079127421 \ + Q69.08365151786553,53.9608681393331 68.37499772324333,51.17242466680129 \ + Q74.4136309339628,75.13284223919301 71.3309887459222,73.69128869058812 Z"; + let path = PathData::from_bezpath(&BezPath::from_svg(OUTLINE).unwrap()); + let (tx, rx) = std::sync::mpsc::channel(); + let input = path.clone(); + std::thread::spawn(move || tx.send(offset_path(&input, 1.8350459854022054, Join::Miter, 4.0))); + let Ok(out) = rx.recv_timeout(std::time::Duration::from_secs(30)) else { + // the stuck sweep keeps allocating on its thread: end the test binary now + eprintln!("Offset Path didn't finish in 30 s (issue #1028)"); + std::process::exit(101); + }; + let (before, after) = (crate::area(&path, FillRule::NonZero), crate::area(&out, FillRule::NonZero)); + assert_eq!(out.subpaths.len(), 1); + assert!(after > before, "{before} → {after}"); + } } From f5bf32744741a83fb093e03b56f2bd283307b6c0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 04:36:24 +0700 Subject: [PATCH 58/97] Review: rustfmt; ROADMAP progress log (#1028) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/pathops/src/boolean.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e641fc2fb..9c018a015 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 7108f770a..d3143c8c8 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -695,7 +695,8 @@ mod tests { // Issue #1028: linesweeper 0.5 handed back a contour running out along an input curve and // back by a piece that doesn't follow it, which refit into a visible lens. let c = arc(); - let tidy = Tidy::keeping(DEFAULT_PRECISION, [&BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)])]); + let tidy = + Tidy::keeping(DEFAULT_PRECISION, [&BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)])]); let out = c.subsegment(0.2..0.6); let mut contour = BezPath::new(); contour.move_to(out.p0); From faa3678a3f1fba8d5a58eb7281fcbcbacaa51330 Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Fri, 9 Oct 2026 17:48:31 +0000 Subject: [PATCH 59/97] UI: stack floating panel groups into sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Floating panel groups dropped on another group's top or bottom edge join it in a set: the groups stack top to bottom, move together by the top group's title bar (whose × docks them all), and a group dragged by its tab strip leaves the set. A double-click on a tab collapses a group to its tabs. Sets and collapsed groups are kept in preferences and workspaces, and window.panel.float takes below, above and collapsed. Behaviour from public documentation: https://helpx.adobe.com/illustrator/using/workspace-basics.html Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/ui-egui/src/floating.rs | 618 ++++++++++++++++++++++------ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/state.rs | 39 +- docs/control-protocol.md | 17 +- 14 files changed, 575 insertions(+), 122 deletions(-) diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 3080a25cd..f0edd063d 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -9,9 +9,16 @@ //! × of a group puts its panels back in the dock too. The Tools panel floats the same way by its //! title bar ([`crate::toolbar`]) and docks again on the window's left edge. //! -//! `window.panel.float` / `window.panel.dock` do the same for agents. The groups and the Tools -//! panel's position are kept with the preferences and in user workspaces, and a position saved on -//! a bigger window is clamped into this one when drawn. +//! Dropped on the top or bottom edge of another group (a line shows where), a group joins it in a +//! set: the groups stack top to bottom, each showing one of its panels, and move together by the +//! top group's title bar, whose × docks them all. A group dragged by its tab strip (a lone panel's +//! tab, or the strip right of the tabs) leaves its set and floats on its own. A double-click on a +//! tab collapses a group to its tabs, and another expands it. +//! +//! `window.panel.float` (`onto`, `below`, `above`, `collapsed`) / `window.panel.dock` do the same +//! for agents. The groups, their sets and the Tools panel's position are kept with the preferences +//! and in user workspaces, and a position saved on a bigger window is clamped into this one when +//! drawn. use egui::{CornerRadius, Id, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2}; use serde_json::{Value, json}; @@ -24,6 +31,10 @@ use crate::{VectorcraftApp, dock, icons, panels}; pub const TITLE: f32 = 14.0; /// Height of a floating group's tab strip. pub const STRIP: f32 = 26.0; +/// Height of the bar between two groups of a set. +pub const GAP: f32 = 3.0; +/// How far from a group's top or bottom edge a drop stacks it above or below the group. +const EDGE: f32 = 7.0; /// Where `window.panel.float` floats a group given no position; each further group 24 points /// lower right. const FLOAT_AT: [f32; 2] = [420.0, 120.0]; @@ -58,6 +69,10 @@ enum Drop { Dock, /// Stacked with the floating group holding this panel. Stack(&'static str), + /// In a set with the floating group holding this panel, below it. + Below(&'static str), + /// In a set with the floating group holding this panel, above it (the top group of its set). + Above(&'static str), } fn move_id() -> Id { @@ -80,11 +95,17 @@ pub(crate) fn tools_zone_id() -> Id { Id::new("floating-tools-zone") } -/// The area of the floating group whose first panel is `first`. +/// The area of the floating group whose first panel is `first` (of the set it heads). pub fn area_id(first: &str) -> Id { Id::new(("floating-panels", first)) } +/// The floating group whose first panel is `first`, as last drawn (egui temp memory): in a set, +/// from the bar above its tabs to its panel's bottom. +pub(crate) fn group_rect_id(first: &str) -> Id { + Id::new(("floating-group-rect", first)) +} + /// Panel `id` (`window.panel`) as a static string and its English label, if it is a panel. fn panel(id: &str) -> Option<(&'static str, &'static str)> { all_panels().find(|(p, _)| *p == id) @@ -95,6 +116,71 @@ pub fn group_of(ui: &UiState, id: &str) -> Option { ui.floating_panels.iter().position(|g| g.panels.iter().any(|p| p == id)) } +/// The floating groups drawn with group `gi`, top to bottom: the groups of its set, or it alone. +pub fn set_of(ui: &UiState, gi: usize) -> Vec { + match ui.floating_panels.get(gi).and_then(|g| g.column) { + Some(c) => ui.floating_panels.iter().enumerate().filter(|(_, g)| g.column == Some(c)).map(|(i, _)| i).collect(), + None => vec![gi], + } +} + +/// Group `gi` heads its set (or floats alone): it draws the set's title bar. +fn leads(ui: &UiState, gi: usize) -> bool { + set_of(ui, gi).first() == Some(&gi) +} + +/// Move the set of group `gi` to `pos`. +fn set_pos(ui: &mut UiState, gi: usize, pos: [f32; 2]) { + for i in set_of(ui, gi) { + if let Some(g) = ui.floating_panels.get_mut(i) { + g.pos = pos; + } + } +} + +/// The panels of the groups `set`, in order. +fn ids_in(ui: &UiState, set: &[usize]) -> Vec<&'static str> { + set.iter().filter_map(|&i| ui.floating_panels.get(i)).flat_map(ids_of).collect() +} + +/// The panel shown by group `gi`. +fn shown(ui: &UiState, gi: usize) -> Option<&'static str> { + let g = ui.floating_panels.get(gi)?; + let ids = ids_of(g); + ids.get(g.active).or(ids.first()).copied() +} + +/// Put the groups `moved` in a set with the group holding `onto` (not one of them), right below or +/// right above it, at its position. +pub fn attach(ui: &mut UiState, moved: &[usize], onto: &str, below: bool) { + let Some(target) = group_of(ui, onto).filter(|t| !moved.contains(t)) else { return }; + let column = match ui.floating_panels.get(target).and_then(|g| g.column) { + Some(c) => c, + None => ui.floating_panels.iter().filter_map(|g| g.column).max().map_or(1, |c| c.saturating_add(1)), + }; + let Some(pos) = ui.floating_panels.get(target).map(|g| g.pos) else { return }; + let (mut taken, mut kept) = (vec![], vec![]); + for (i, mut g) in ui.floating_panels.drain(..).enumerate() { + if i == target { + g.column = Some(column); + } + if moved.contains(&i) { + g.column = Some(column); + g.pos = pos; + taken.push(g); + } else { + kept.push(g); + } + } + let at = kept.iter().position(|g| g.panels.iter().any(|p| p == onto)).map_or(kept.len(), |i| if below { i + 1 } else { i }); + kept.splice(at..at, taken); + ui.floating_panels = kept; + FloatingPanels::tidy(&mut ui.floating_panels); + if let Some(tab) = shown_tab(ui) { + ui.dock_tab = tab; + } +} + /// The panels of group `g`, as static ids. fn ids_of(g: &FloatingPanels) -> Vec<&'static str> { g.panels.iter().filter_map(|p| panel(p)).map(|(id, _)| id).collect() @@ -118,6 +204,7 @@ fn take(ui: &mut UiState, ids: &[&str]) { g.active = active.and_then(|a| g.panels.iter().position(|p| *p == a)).unwrap_or(0); !g.panels.is_empty() }); + FloatingPanels::tidy(&mut ui.floating_panels); } /// Float `ids` (out of the dock or of the group they were in) as a new group with its top-left @@ -129,7 +216,7 @@ pub fn float(ui: &mut UiState, ids: &[&str], active: &str, pos: Pos2) { } let panels: Vec = ids.iter().map(|id| id.to_string()).collect(); let active = panels.iter().position(|p| p == active).unwrap_or(0); - ui.floating_panels.push(FloatingPanels { panels, active, pos: [pos.x, pos.y], width: None }); + ui.floating_panels.push(FloatingPanels { panels, active, pos: [pos.x, pos.y], ..Default::default() }); // The tabbed group shows another of its tabs when the one it showed floats. if let Some(tab) = shown_tab(ui) { ui.dock_tab = tab; @@ -214,11 +301,22 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2 let zone = |id: Id| ctx.data(|d| d.get_temp::(id)).filter(|r| r.contains(at)); let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) }; let own = group_of(&app.ui, id); - // Another group's title bar and tabs stack with it. + let moving = own.map(|i| set_of(&app.ui, i)).unwrap_or_default(); + // Another group's top or bottom edge puts the moved groups in a set with it; its title bar and + // tabs stack them as tabs. for (i, g) in app.ui.floating_panels.iter().enumerate() { - let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| Some(i) != own) else { continue }; - let head = ctx.memory(|m| m.area_rect(area_id(first))).map(|r| Rect::from_min_size(r.min, vec2(r.width(), 1.0 + TITLE + STRIP))); - if let Some(head) = head.filter(|h| h.contains(at)) { + let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue }; + let Some(r) = ctx.data(|d| d.get_temp::(group_rect_id(first))) else { continue }; + let line = |y: f32| Rect::from_x_y_ranges(r.x_range(), (y - 2.0)..=(y + 2.0)); + if Rect::from_x_y_ranges(r.x_range(), (r.bottom() - EDGE)..=(r.bottom() + EDGE)).contains(at) { + return Some((Drop::Below(first), line(r.bottom()))); + } + let lead = leads(&app.ui, i); + if lead && Rect::from_x_y_ranges(r.x_range(), (r.top() - EDGE)..=r.top()).contains(at) { + return Some((Drop::Above(first), line(r.top()))); + } + let head = Rect::from_min_size(r.min, vec2(r.width(), if lead { 1.0 + TITLE + STRIP } else { GAP + STRIP })); + if head.contains(at) { return Some((Drop::Stack(first), head)); } } @@ -238,6 +336,13 @@ fn highlight(ctx: &egui::Context, zone: Rect) { painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); } +/// Show where a drop puts the moved groups in a set: a line over the floating groups. +fn insertion_line(ctx: &egui::Context, line: Rect) { + let t = Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("floating-drop-line"))); + painter.rect_filled(line, CornerRadius::same(2), t.accent); +} + /// Carry a move on (at the start of a frame, before the panels are drawn): the group or Tools panel /// follows the pointer and the zone it would drop on lights up; the release drops it there. pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { @@ -251,16 +356,18 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { { match m.what { Moving::Panel(id) => { - if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) { - g.pos = [p.x, p.y]; + if let Some(gi) = group_of(&app.ui, id) { + set_pos(&mut app.ui, gi, [p.x, p.y]); } } Moving::Tools if app.ui.toolbar_pos.is_some() => app.ui.toolbar_pos = Some([p.x, p.y]), Moving::Tools => {} } } - if let Some((_, zone)) = target { - highlight(ctx, zone); + match target { + Some((Drop::Below(_) | Drop::Above(_), line)) => insertion_line(ctx, line), + Some((_, zone)) => highlight(ctx, zone), + None => {} } return; } @@ -270,9 +377,11 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { app.ui.toolbar_pos = None; return; }; - let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)) else { return }; - let ids = ids_of(g); - let active = ids.get(g.active).copied().unwrap_or(id); + // The moved set: its groups' panels, showing the top group's. + let Some(gi) = group_of(&app.ui, id) else { return }; + let set = set_of(&app.ui, gi); + let ids = ids_in(&app.ui, &set); + let active = set.first().and_then(|&i| shown(&app.ui, i)).unwrap_or(id); match drop { Drop::Dock => { dock(&mut app.ui, &ids, active); @@ -282,6 +391,8 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { } } Drop::Stack(onto) => stack(&mut app.ui, &ids, active, onto), + Drop::Below(onto) => attach(&mut app.ui, &set, onto, true), + Drop::Above(onto) => attach(&mut app.ui, &set, onto, false), } } @@ -289,130 +400,217 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { enum Action { /// Show the group's `k`th tab. Activate(usize, usize), - /// Its × puts the group's panels back in the dock. - Close(usize), - /// Move the group holding this panel, drawn at this corner. + /// A double-click on a tab collapses the group to its tabs, or expands it. + Collapse(usize), + /// Its × puts the panels of the set's groups back in the dock. + Close(Vec), + /// Move the set of the group holding this panel, drawn at this corner. Move(&'static str, Pos2), + /// Take this group out of its set and move it on its own; its tab strip began this far right. + Detach(usize, f32), /// Float this panel out of its group; its tab began this far right. Tear(&'static str, f32), } -/// The floating groups, each a stack of tabs under a title bar. Hidden with the dock (Tab, -/// Presentation Mode). +/// A floating group as drawn: its index, panels, the one shown and whether it's collapsed. +struct Shown { + gi: usize, + ids: Vec<&'static str>, + active: &'static str, + collapsed: bool, +} + +/// The floating groups, each a stack of tabs, stacked in their sets under a title bar. Hidden with +/// the dock (Tab, Presentation Mode). pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 { return; } + let mut action = None; + for gi in 0..app.ui.floating_panels.len() { + if leads(&app.ui, gi) { + let set = set_of(&app.ui, gi); + show_set(app, ctx, &set, &mut action); + } + } + match action { + Some(Action::Activate(gi, k)) => { + if let Some(g) = app.ui.floating_panels.get_mut(gi) + && g.active != k + { + g.active = k; + g.collapsed = false; + } + } + Some(Action::Collapse(gi)) => { + if let Some(g) = app.ui.floating_panels.get_mut(gi) { + g.collapsed = !g.collapsed; + } + } + Some(Action::Close(set)) => { + let ids = ids_in(&app.ui, &set); + let active = set.first().and_then(|&i| shown(&app.ui, i)).unwrap_or_default(); + dock(&mut app.ui, &ids, active); + } + Some(Action::Move(id, pos)) => { + // From where it is drawn (a position saved on a bigger window was clamped into this one). + if let Some(gi) = group_of(&app.ui, id) { + set_pos(&mut app.ui, gi, [pos.x, pos.y]); + } + if let Some(at) = ctx.input(|i| i.pointer.interact_pos()) { + start(ctx, Moving::Panel(id), at - pos); + } + } + Some(Action::Detach(gi, left)) => detach(app, ctx, gi, left), + Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)), + None => {} + } +} + +/// Take group `gi` out of its set under the pointer, its tab strip having begun `left` points from +/// the window's left, and carry on moving it with the pointer. +fn detach(app: &mut VectorcraftApp, ctx: &egui::Context, gi: usize, left: f32) { + let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + let Some(id) = shown(&app.ui, gi) else { return }; + let grab = strip_grab(ctx, left); + let p = at - grab; + if let Some(g) = app.ui.floating_panels.get_mut(gi) { + g.column = None; + g.pos = [p.x, p.y]; + } + FloatingPanels::tidy(&mut app.ui.floating_panels); + start(ctx, Moving::Panel(id), grab); +} + +/// Draw the set of floating groups `set` (top to bottom) as one floating box. +fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action: &mut Option) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); let pointer = ctx.input(|i| i.pointer.interact_pos()); - let mut action = None; - for gi in 0..app.ui.floating_panels.len() { - let Some(g) = app.ui.floating_panels.get(gi) else { break }; - let ids = ids_of(g); - let Some(&first) = ids.first() else { continue }; - let active = ids.get(g.active).copied().unwrap_or(first); - let given = g.width.map(|w| w.min(screen.width() - 16.0)); - let area = area_id(first); - let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(active), 200.0), |r| r.size()); - let pos = clamp(g.pos, size, screen); - // The tabbed group's panels fill (Layers) or scroll (Properties) to near the window's bottom, - // leaving room for their margins (a group that only just fitted would creep up a little each - // frame). - let tall = (screen.bottom() - pos.y - TITLE - STRIP - 60.0).clamp(120.0, 560.0); - let lone = ids.len() == 1; - egui::Area::new(area).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { - let frame = egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO); - frame.show(ui, |ui| { - ui.spacing_mut().item_spacing = Vec2::ZERO; - let tabs: Vec<_> = ids - .iter() - .map(|id| { - let label = panel(id).map_or(*id, |(_, label)| label); - let color = if *id == active { t.text_strong } else { t.text_dim }; - ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color) - }) - .collect(); - let tabs_width: f32 = tabs.iter().map(|g| g.size().x + 24.0).sum(); - let width = ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max).max(given.unwrap_or(0.0)); - ui.set_width(width); - // Title bar: a grip that moves the group, and the × that docks it. - let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover()); - ui.painter().rect_filled(bar, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_strip); - let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE)); - let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("title"), Sense::drag()); - for k in 0..2 { - let y = bar.center().y - 1.5 + k as f32 * 3.0; - ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled)); - } - if grip.drag_started() { - action = Some(Action::Move(active, pos)); - } - grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move")); - let x = ui.interact(close, ui.id().with("close"), Sense::click()); - icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim }); - if x.on_hover_text(tl!("Put back in the dock")).clicked() { - action = Some(Action::Close(gi)); - } - // Tab strip: a click shows a tab, a drag out of the strip floats it on its own. + let groups: Vec = set + .iter() + .filter_map(|&gi| { + let g = app.ui.floating_panels.get(gi)?; + let ids = ids_of(g); + let active = ids.get(g.active).or(ids.first()).copied()?; + Some(Shown { gi, ids, active, collapsed: g.collapsed }) + }) + .collect(); + let Some((lead, first)) = groups.first().and_then(|g| Some((g, *g.ids.first()?))) else { return }; + let Some(saved) = app.ui.floating_panels.get(lead.gi).map(|g| g.pos) else { return }; + let given = set.iter().filter_map(|&i| app.ui.floating_panels.get(i)?.width).reduce(f32::max).map(|w| w.min(screen.width() - 16.0)); + let area = area_id(first); + let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(lead.active), 200.0), |r| r.size()); + let pos = clamp(saved, size, screen); + let alone = groups.len() == 1; + // The tabbed group's panels fill (Layers) or scroll (Properties) to near the window's bottom, + // leaving room for their margins (a group that only just fitted would creep up a little each + // frame); the open panels of a set share that height. + let n = groups.len() as f32; + let open = groups.iter().filter(|g| !g.collapsed).count().max(1) as f32; + let tall = ((screen.bottom() - pos.y - TITLE - n * STRIP - (n - 1.0) * GAP - 60.0) / open).clamp(120.0, 560.0); + let lead_active = lead.active; + egui::Area::new(area).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { + let frame = egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO); + frame.show(ui, |ui| { + ui.spacing_mut().item_spacing = Vec2::ZERO; + let tabs: Vec> = groups + .iter() + .map(|g| { + g.ids + .iter() + .map(|id| { + let label = panel(id).map_or(*id, |(_, label)| label); + let color = if *id == g.active { t.text_strong } else { t.text_dim }; + ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color) + }) + .collect() + }) + .collect(); + let width = groups + .iter() + .zip(&tabs) + .map(|(g, tabs)| { + let tabs_width: f32 = tabs.iter().map(|tab| tab.size().x + 24.0).sum(); + g.ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max) + }) + .fold(given.unwrap_or(0.0), f32::max); + ui.set_width(width); + // Title bar: a grip that moves the set, and the × that docks it. + let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover()); + ui.painter().rect_filled(bar, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_strip); + let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE)); + let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("title"), Sense::drag()); + for k in 0..2 { + let y = bar.center().y - 1.5 + k as f32 * 3.0; + ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled)); + } + if grip.drag_started() { + *action = Some(Action::Move(lead_active, pos)); + } + grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move")); + let x = ui.interact(close, ui.id().with("close"), Sense::click()); + icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim }); + if x.on_hover_text(tl!("Put back in the dock")).clicked() { + *action = Some(Action::Close(set.to_vec())); + } + for (k, (g, tabs)) in groups.iter().zip(tabs).enumerate() { + let top = if k == 0 { + bar.top() + } else { + let (gap, _) = ui.allocate_exact_size(vec2(width, GAP), Sense::hover()); + ui.painter().rect_filled(gap, 0.0, t.tab_strip); + gap.top() + }; + // Tab strip: a click shows a tab, a double-click collapses the group, a drag out of + // the strip floats a tab on its own; a lone tab or the strip right of the tabs moves + // the group (out of its set). let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover()); ui.painter().rect_filled(strip, 0.0, t.panel_darker); + let lone = g.ids.len() == 1; let mut left = strip.left(); - for (k, (&id, galley)) in ids.iter().zip(tabs).enumerate() { + for (j, (&id, galley)) in g.ids.iter().zip(tabs).enumerate() { let r = Rect::from_min_size(pos2(left, strip.top()), vec2(galley.size().x + 24.0, STRIP)); left = r.right(); let resp = ui.interact(r, ui.id().with(("tab", id)), Sense::click_and_drag()); - if id == active { + if id == g.active && !g.collapsed { ui.painter().rect_filled(r, 0.0, t.panel); } ui.painter().galley(pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); - if resp.clicked() { - action = Some(Action::Activate(gi, k)); + if resp.double_clicked() { + *action = Some(Action::Collapse(g.gi)); + } else if resp.clicked() { + *action = Some(Action::Activate(g.gi, j)); + } else if lone && alone && resp.drag_started() { + *action = Some(Action::Move(id, pos)); } else if lone && resp.drag_started() { - action = Some(Action::Move(id, pos)); + *action = Some(Action::Detach(g.gi, strip.left())); } else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) { - action = Some(Action::Tear(id, r.left())); + *action = Some(Action::Tear(id, r.left())); } } let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); let rest = Rect::from_min_max(pos2(left, strip.top()), pos2(menu.left() - 4.0, strip.bottom())); if rest.width() > 0.0 { - let resp = ui.interact(rest, ui.id().with("strip"), Sense::drag()); + let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag()); if resp.drag_started() { - action = Some(Action::Move(active, pos)); + *action = Some(if alone { Action::Move(g.active, pos) } else { Action::Detach(g.gi, strip.left()) }); } - resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move")); + let tip = if alone { tl!("Drag to move") } else { tl!("Drag to move out of the set") }; + resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip); } - panels::panel_menu(app, ui, active, menu); - dock::panel_body(app, ui, active, width, tall); - }); + panels::panel_menu(app, ui, g.active, menu); + if !g.collapsed { + dock::panel_body(app, ui, g.active, width, tall); + } + let bottom = ui.min_rect().bottom().max(strip.bottom()); + if let Some(&id) = g.ids.first() { + let rect = Rect::from_min_max(pos2(strip.left(), top), pos2(strip.left() + width, bottom)); + ui.ctx().data_mut(|d| d.insert_temp(group_rect_id(id), rect)); + } + } }); - } - match action { - Some(Action::Activate(gi, k)) => { - if let Some(g) = app.ui.floating_panels.get_mut(gi) { - g.active = k; - } - } - Some(Action::Close(gi)) => { - if let Some(g) = app.ui.floating_panels.get(gi) { - let ids = ids_of(g); - let active = ids.get(g.active).copied().unwrap_or_default(); - dock(&mut app.ui, &ids, active); - } - } - Some(Action::Move(id, pos)) => { - // From where it is drawn (a position saved on a bigger window was clamped into this one). - if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) { - g.pos = [pos.x, pos.y]; - } - if let Some(at) = pointer { - start(ctx, Moving::Panel(id), at - pos); - } - } - Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)), - None => {} - } + }); } /// `window.panel.float` and `window.panel.dock`; `None` for other commands. @@ -455,35 +653,82 @@ fn float_or_dock(app: &mut VectorcraftApp, float_it: bool, p: &Value) -> Result< (true, None) if DockTab::from_id(id).is_some() => docked_tabs(&app.ui).map(|t| t.info().0).collect(), _ => vec![id], }; + let collapsed = crate::menus::opt_bool(p, "collapsed")?; + let onto = p.get("onto").and_then(Value::as_str); + let below = p.get("below").and_then(Value::as_str); + let above = p.get("above").and_then(Value::as_str); + if [onto, below, above].iter().filter(|o| o.is_some()).count() > 1 { + return Err("give only one of onto, below and above".into()); + } app.ui.dock = true; if !float_it { dock(&mut app.ui, &ids, id); return Ok(json!({ "panel": id, "floating": false })); } - match p.get("onto").and_then(Value::as_str) { + if let Some((next, under)) = below.map(|b| (b, true)).or(above.map(|a| (a, false))) { + let next = crate::menus::normalize_panel(next) + .filter(|o| !ids.contains(o) && group_of(&app.ui, o).is_some()) + .ok_or("below and above must name a panel floating in another group")?; + // The panels as a group of their own (their group already, or floated out), in the set. + let own = own.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.panels.len() == ids.len())); + if own.is_none() { + float(&mut app.ui, &ids, id, Pos2::ZERO); + } + if let Some(gi) = group_of(&app.ui, id) { + attach(&mut app.ui, &[gi], next, under); + } + } else { + float_onto(app, &ids, id, own, onto, at)?; + } + if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) { + g.active = g.panels.iter().position(|q| q == id).unwrap_or(g.active); + if let Some(c) = collapsed { + g.collapsed = c; + } + } + let gi = group_of(&app.ui, id); + let g = gi.and_then(|i| app.ui.floating_panels.get(i)); + let set: Vec<&Vec> = + gi.map(|i| set_of(&app.ui, i)).unwrap_or_default().iter().filter_map(|&i| app.ui.floating_panels.get(i)).map(|g| &g.panels).collect(); + let (group, pos, collapsed) = (g.map(|g| &g.panels), g.map(|g| g.pos), g.is_some_and(|g| g.collapsed)); + Ok(json!({ "panel": id, "floating": true, "group": group, "set": set, "pos": pos, "collapsed": collapsed })) +} + +/// Float `ids` (`id` among them; `own`, the group holding `id`) as their own group at `at`, or +/// stacked as tabs `onto` another group. +fn float_onto( + app: &mut VectorcraftApp, + ids: &[&'static str], + id: &'static str, + own: Option, + onto: Option<&str>, + at: Option, +) -> Result<(), String> { + match onto { Some(onto) => { let onto = crate::menus::normalize_panel(onto) .filter(|o| !ids.contains(o) && group_of(&app.ui, o).is_some_and(|g| Some(g) != own)) .ok_or("onto must name a panel floating in another group")?; - stack(&mut app.ui, &ids, id, onto); + stack(&mut app.ui, ids, id, onto); } // Its own group already: moved there (or left where it is), showing the panel. None => match own.and_then(|i| app.ui.floating_panels.get_mut(i)).filter(|g| g.panels.len() == ids.len()) { + // Moved to `at` takes it out of its set; else it stays where it is. Some(g) => { - g.active = g.panels.iter().position(|p| p == id).unwrap_or(g.active); if let Some(at) = at { g.pos = [at.x, at.y]; + g.column = None; + FloatingPanels::tidy(&mut app.ui.floating_panels); } } None => { let n = app.ui.floating_panels.len() as f32; let at = at.unwrap_or(pos2(FLOAT_AT[0] + 24.0 * n, FLOAT_AT[1] + 24.0 * n)); - float(&mut app.ui, &ids, id, at); + float(&mut app.ui, ids, id, at); } }, } - let g = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)); - Ok(json!({ "panel": id, "floating": true, "group": g.map(|g| &g.panels), "pos": g.map(|g| g.pos) })) + Ok(()) } #[cfg(test)] @@ -775,7 +1020,7 @@ mod tests { })) .unwrap(); let edited = edited.sanitized(); - assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0], width: None }]); + assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0], ..Default::default() }]); h.settle(); let g = h.group("layers"); let screen = Rect::from_min_size(Pos2::ZERO, SCREEN); @@ -811,6 +1056,149 @@ mod tests { assert!(app.ui.toolbar && app.ui.toolbar_pos == Some(TOOLS_AT), "floating shows the Tools panel"); } + /// The groups' sets: each group's panels and its column, in list order. + fn sets(groups: &[FloatingPanels]) -> Vec<(Vec<&str>, Option)> { + groups.iter().map(|g| (g.panels.iter().map(String::as_str).collect(), g.column)).collect() + } + + #[test] + fn groups_dropped_on_an_edge_stack_into_a_set_that_moves_together_and_tears_apart() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "color", "x": 500, "y": 150})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "swatches", "x": 950, "y": 450})).unwrap(); + h.settle(); + // Swatches' title bar held over Color's bottom edge: a line shows it goes below. + let color = h.temp_rect(group_rect_id("color")); + let below = pos2(color.center().x, color.bottom() + 2.0); + h.hold(h.title("swatches"), below); + let target = drop_target(&h.app, &h.ctx, Moving::Panel("swatches"), below); + assert_eq!(target.map(|t| t.0), Some(Drop::Below("color"))); + assert!(target.is_some_and(|t| t.1.height() <= 4.0), "a line, not a box: {target:?}"); + h.release(below); + let s = sets(&h.app.ui.floating_panels); + assert_eq!((s[0].0.clone(), s[1].0.clone()), (vec!["color"], vec!["swatches"]), "Color, then Swatches"); + assert!(s[0].1.is_some() && s[0].1 == s[1].1, "one set: {s:?}"); + assert_eq!(h.app.ui.floating_panels[0].pos, h.app.ui.floating_panels[1].pos, "at the set's corner"); + // Drawn as one box: Swatches right under Color. + let (color, swatches) = (h.temp_rect(group_rect_id("color")), h.temp_rect(group_rect_id("swatches"))); + assert!((swatches.top() - color.bottom()).abs() < 1.0 && swatches.left() == color.left(), "{color:?} {swatches:?}"); + assert!(h.group("color").contains_rect(swatches.shrink(1.0)), "in Color's area"); + // The set moves by its title bar. + let to = pos2(300.0, 120.0); + let grab = h.title("color") - h.group("color").min; + h.drag(h.title("color"), to); + let moved = [to.x - grab.x, to.y - grab.y]; + assert!(h.app.ui.floating_panels.iter().all(|g| (g.pos[0] - moved[0]).abs() < 1.0 && (g.pos[1] - moved[1]).abs() < 1.0)); + assert_eq!(sets(&h.app.ui.floating_panels).len(), 2); + // Swatches' lone tab dragged away takes it out of the set: both float on their own. + let swatches = h.temp_rect(group_rect_id("swatches")); + let tab = pos2(swatches.left() + 20.0, swatches.top() + GAP + STRIP / 2.0); + let away = pos2(1000.0, 600.0); + h.drag(tab, away); + assert_eq!(sets(&h.app.ui.floating_panels), [(vec!["color"], None), (vec!["swatches"], None)]); + assert!(h.group("swatches").contains(away), "under the pointer: {:?}", h.group("swatches")); + // Dropped on Color's top edge, it goes above Color, which it now heads. + let color = h.temp_rect(group_rect_id("color")); + let above = pos2(color.center().x, color.top() - 3.0); + h.hold(h.title("swatches"), above); + assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("swatches"), above).map(|t| t.0), Some(Drop::Above("color"))); + h.release(above); + let s = sets(&h.app.ui.floating_panels); + assert_eq!((s[0].0.clone(), s[1].0.clone()), (vec!["swatches"], vec!["color"])); + assert!(s[0].1.is_some() && s[0].1 == s[1].1); + // A group's tab strip still takes tabs: Stroke dropped on Color's tabs joins Color's group. + h.app.run("window.panel.float", json!({"panel": "stroke", "x": 1000, "y": 650})).unwrap(); + h.settle(); + let color = h.temp_rect(group_rect_id("color")); + let tabs = pos2(color.center().x, color.top() + GAP + STRIP / 2.0); + h.drag(h.title("stroke"), tabs); + assert_eq!(sets(&h.app.ui.floating_panels).iter().map(|s| s.0.clone()).collect::>(), [vec!["swatches"], vec!["color", "stroke"]]); + // The set's × docks every group. + let g = h.group("swatches"); + h.click(pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0)); + assert!(h.groups().is_empty(), "docked: {:?}", h.groups()); + } + + #[test] + fn a_double_click_on_a_tab_collapses_a_floating_group_and_another_expands_it() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 150})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "stroke", "below": "layers"})).unwrap(); + h.settle(); + let open = h.temp_rect(group_rect_id("layers")); + let tab = pos2(open.left() + 20.0, open.top() + 1.0 + TITLE + STRIP / 2.0); + let double = |h: &mut Harness| { + // Well after any earlier click, so the first click doesn't make a double-click with it. + h.time += 1.0; + h.frame(vec![Event::PointerMoved(tab), button(tab, true)]); + h.frame(vec![button(tab, false)]); + h.frame(vec![button(tab, true)]); + h.frame(vec![button(tab, false)]); + h.settle(); + }; + double(&mut h); + assert!(h.app.ui.floating_panels[0].collapsed, "collapsed"); + let shut = h.temp_rect(group_rect_id("layers")); + assert!(shut.height() < 1.0 + TITLE + STRIP + 2.0, "only its tabs: {shut:?}"); + assert!(h.temp_rect(group_rect_id("stroke")).top() < open.bottom(), "Stroke moved up under it"); + double(&mut h); + assert!(!h.app.ui.floating_panels[0].collapsed, "expanded"); + // Collapsed by command, Window › Layers expands it. + h.app.run("window.panel.float", json!({"panel": "layers", "collapsed": true})).unwrap(); + assert!(h.app.ui.floating_panels[0].collapsed); + h.app.run("window.panel", json!({"panel": "layers"})).unwrap(); + assert!(!h.app.ui.floating_panels[0].collapsed); + } + + #[test] + fn sets_by_command_are_checked_and_kept_in_workspaces_and_preferences() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.panel.float", json!({"panel": "layers", "x": 100, "y": 50})).unwrap(); + assert!(app.run("window.panel.float", json!({"panel": "color", "below": "swatches"})).is_err(), "Swatches isn't floating"); + assert!(app.run("window.panel.float", json!({"panel": "color", "onto": "layers", "below": "layers"})).is_err(), "one of them"); + assert!(app.run("window.panel.float", json!({"panel": "layers", "below": "layers"})).is_err(), "not below itself"); + assert!(app.run("window.panel.float", json!({"panel": "layers", "collapsed": "yes"})).is_err()); + assert_eq!(app.ui.floating_panels.len(), 1, "nothing floated by the errors"); + // Color floats straight into the set, below Layers; Stroke above it. + let r = app.run("window.panel.float", json!({"panel": "color", "below": "Layers"})).unwrap(); + assert_eq!(r["set"], json!([["layers"], ["color"]])); + let r = app.run("window.panel.float", json!({"panel": "stroke", "above": "color"})).unwrap(); + assert_eq!(r["set"], json!([["layers"], ["stroke"], ["color"]])); + assert!(app.ui.floating_panels.iter().all(|g| g.pos == [100.0, 50.0]), "at the set's corner"); + // Stroke's whole group moves to the set's bottom. + app.run("window.panel.float", json!({"panel": "stroke", "below": "color"})).unwrap(); + assert_eq!(sets(&app.ui.floating_panels).iter().map(|s| s.0.clone()).collect::>(), [vec!["layers"], vec!["color"], vec!["stroke"]]); + // Kept in a workspace and the preferences. + app.run("window.panel.float", json!({"panel": "color", "collapsed": true})).unwrap(); + app.run("window.workspace.new", json!({"name": "Sets"})).unwrap(); + let saved = app.ui.floating_panels.clone(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + assert!(app.ui.floating_panels.is_empty()); + app.run("window.workspace", json!({"name": "Sets"})).unwrap(); + assert_eq!(app.ui.floating_panels, saved); + let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(prefs.sanitized().floating_panels, saved); + // Moved to x, y, a group leaves its set; the set left with one group floats on its own. + let r = app.run("window.panel.float", json!({"panel": "color", "x": 600, "y": 400})).unwrap(); + assert_eq!((r["set"].clone(), r["pos"].clone()), (json!([["color"]]), json!([600.0, 400.0]))); + app.run("window.panel.dock", json!({"panel": "layers"})).unwrap(); + assert_eq!(sets(&app.ui.floating_panels), [(vec!["stroke"], None), (vec!["color"], None)]); + // A hand-edited file: a set's groups kept together at its first group's corner, a lone one alone. + let edited: UiState = serde_json::from_value(json!({ + "floating_panels": [ + {"panels": ["layers"], "pos": [1, 2], "column": 4}, + {"panels": ["color"], "pos": [9, 9]}, + {"panels": ["stroke"], "pos": [5, 6], "column": 4}, + {"panels": ["swatches"], "pos": [7, 8], "column": 9}, + ], + })) + .unwrap(); + let edited = edited.sanitized(); + let s = sets(&edited.floating_panels); + assert_eq!(s, [(vec!["layers"], Some(4)), (vec!["stroke"], Some(4)), (vec!["color"], None), (vec!["swatches"], None)]); + assert_eq!(edited.floating_panels[1].pos, [1.0, 2.0]); + } + fn floating_pos(app: &VectorcraftApp, id: &str) -> [f32; 2] { group_of(&app.ui, id).map(|i| app.ui.floating_panels[i].pos).unwrap() } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4cafe5776..e3a0dda6a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -628,6 +628,7 @@ % of all commits % všech commitů Lines +/− Řádky +/− Drag to move Přetažením přesunete + Drag to move out of the set Přetažením přesunete mimo sadu Drag to float as a toolbar Přetažením vytvoříte plovoucí panel nástrojů Put back in the toolbar Vrátit do panelu nástrojů Float Panel Plovoucí panel diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9769b1107..7d5beda50 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3317,6 +3317,7 @@ axis Horizontal Horizontal axis Vertical Vertical axis Both Ambos Drag to move Arrastrar para mover + Drag to move out of the set Arrastrar para sacar del conjunto Drag to float as a toolbar Arrastrar para flotar como barra de herramientas Put back in the toolbar Devolver a la barra de herramientas Float Panel Hacer flotar el panel @@ -3873,6 +3874,8 @@ axis Both Ambos @msg tool (a tool id) is required se requiere tool (un id de herramienta) @msg {tool} isn't in a toolbar group of several tools {tool} no está en un grupo de la barra de herramientas con varias herramientas @msg onto must name a panel floating in another group onto debe nombrar un panel que flote en otro grupo +@msg below and above must name a panel floating in another group below y above deben nombrar un panel que flote en otro grupo +@msg give only one of onto, below and above indica solo uno de onto, below y above @msg x and y must both be numbers x e y deben ser números @msg "{0}" is not a colour: write it as #rrggbb "{0}" no es un color: escríbelo como #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead esto no es un logotipo de colores planos ({_1}% de sus zonas sólidas coinciden con unos pocos colores planos), así que el modo Logotipo plano convertiría sus degradados en manchas: usa el modo Color diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 74c185339..00d33e735 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3314,6 +3314,7 @@ axis Horizontal Horizontal axis Vertical Vertical axis Both Les deux Drag to move Faire glisser pour déplacer + Drag to move out of the set Faire glisser pour sortir de l’ensemble Drag to float as a toolbar Faire glisser pour détacher en barre d’outils flottante Put back in the toolbar Remettre dans la barre d’outils Float Panel Rendre le panneau flottant @@ -3870,6 +3871,8 @@ axis Both Les deux @msg tool (a tool id) is required tool (un identifiant d’outil) est obligatoire @msg {tool} isn't in a toolbar group of several tools {tool} ne fait pas partie d’un groupe de plusieurs outils dans la barre d’outils @msg onto must name a panel floating in another group onto doit désigner un panneau flottant dans un autre groupe +@msg below and above must name a panel floating in another group below et above doivent désigner un panneau flottant dans un autre groupe +@msg give only one of onto, below and above indiquez un seul de onto, below et above @msg x and y must both be numbers x et y doivent tous deux être des nombres @msg "{0}" is not a colour: write it as #rrggbb « {0} » n’est pas une couleur : écrivez-la sous la forme #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ce n’est pas un logo en aplats ({_1} % de ses zones pleines correspondent à quelques couleurs unies), donc le mode Logo en aplats transformerait ses dégradés en taches : utilisez le mode Couleur diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5d423c7be..c5b53a789 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3317,6 +3317,7 @@ axis Horizontal Orizzontale axis Vertical Verticale axis Both Entrambi Drag to move Trascina per spostare + Drag to move out of the set Trascina per spostare fuori dall’insieme Drag to float as a toolbar Trascina per rendere mobile come barra degli strumenti Put back in the toolbar Riporta nella barra degli strumenti Float Panel Rendi mobile pannello @@ -3873,6 +3874,8 @@ axis Both Entrambi @msg tool (a tool id) is required tool (un id di strumento) è obbligatorio @msg {tool} isn't in a toolbar group of several tools {tool} non fa parte di un gruppo di più strumenti nella barra degli strumenti @msg onto must name a panel floating in another group onto deve indicare un pannello mobile in un altro gruppo +@msg below and above must name a panel floating in another group below e above devono indicare un pannello mobile in un altro gruppo +@msg give only one of onto, below and above indica solo uno tra onto, below e above @msg x and y must both be numbers x e y devono essere entrambi numeri @msg "{0}" is not a colour: write it as #rrggbb "{0}" non è un colore: scrivilo come #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead questo non è un logo a colori piatti ({_1}% delle sue aree piene corrisponde a pochi colori piatti), quindi la modalità Logo piatto trasformerebbe i suoi sfumati in macchie: usa la modalità Colore diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ec1705826..547525c38 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3265,6 +3265,7 @@ axis Horizontal 水平方向 axis Vertical 垂直方向 axis Both 両方 Drag to move 移動するにはドラッグ + Drag to move out of the set ドラッグしてセットから出す Drag to float as a toolbar ドラッグしてツールバーとして浮かせる Put back in the toolbar ツールバーに戻す Float Panel パネルをフローティング diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c1d67d4bc..b7df5bd07 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2697,6 +2697,7 @@ % of all commits % de todos os commits Lines +/− Linhas +/− Drag to move Arraste para mover + Drag to move out of the set Arraste para tirar do conjunto Drag to float as a toolbar Arraste para flutuar como barra de ferramentas Put back in the toolbar Devolver à barra de ferramentas Float Panel Flutuar Painel diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 14543a6cd..e9642d731 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3313,6 +3313,7 @@ axis Horizontal Горизонтально axis Vertical Вертикально axis Both Оба Drag to move Перетащите для перемещения + Drag to move out of the set Перетащите, чтобы вынести из набора Drag to float as a toolbar Перетащите, чтобы сделать плавающей панелью инструментов Put back in the toolbar Вернуть на панель инструментов Float Panel Сделать панель плавающей @@ -3869,6 +3870,8 @@ axis Both Оба @msg tool (a tool id) is required требуется tool (идентификатор инструмента) @msg {tool} isn't in a toolbar group of several tools {tool} не входит в группу панели инструментов из нескольких инструментов @msg onto must name a panel floating in another group onto должен называть панель, плавающую в другой группе +@msg below and above must name a panel floating in another group below и above должны называть панель, плавающую в другой группе +@msg give only one of onto, below and above укажите только один из onto, below и above @msg x and y must both be numbers x и y должны быть числами @msg "{0}" is not a colour: write it as #rrggbb «{0}» не является цветом: запишите его как #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead это не логотип из плоских цветов ({_1}% его сплошных областей совпадает с несколькими плоскими цветами), поэтому режим «Плоский логотип» превратил бы градиенты в пятна: используйте режим «Цвет» diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f46baa1e2..5ecd32f36 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3305,6 +3305,7 @@ axis Horizontal Горизонтальна axis Vertical Вертикальна axis Both Обидві Drag to move Перетягніть для переміщення + Drag to move out of the set Перетягніть, щоб винести з набору Drag to float as a toolbar Перетягніть, щоб від’єднати панель інструментів Put back in the toolbar Повернути на панель інструментів Float Panel Від’єднати панель @@ -3856,6 +3857,8 @@ axis Both Обидві @msg tool (a tool id) is required потрібно tool (ідентифікатор інструменту) @msg {tool} isn't in a toolbar group of several tools {tool} не входить до групи панелі інструментів з кількох інструментів @msg onto must name a panel floating in another group onto має називати панель, що плаває в іншій групі +@msg below and above must name a panel floating in another group below і above мають називати панель, що плаває в іншій групі +@msg give only one of onto, below and above вкажіть лише один із onto, below і above @msg x and y must both be numbers x і y мають бути числами @msg "{0}" is not a colour: write it as #rrggbb "{0}" не є кольором: запишіть його як #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead це не логотип із плоских кольорів ({_1}% його суцільних областей збігається з кількома плоскими кольорами), тому режим «Плоский логотип» перетворив би градієнти на плями: використовуйте режим «Колір» diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6358d2ae8..05d705b5a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3517,6 +3517,7 @@ axis Horizontal 水平 axis Vertical 垂直 axis Both 两者 Drag to move 拖动以移动 + Drag to move out of the set 拖动以移出该组合 Drag to float as a toolbar 拖动使其浮动为工具栏 Put back in the toolbar 放回工具栏 Float Panel 浮动面板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2df10453f..e5e999800 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3295,6 +3295,7 @@ axis Horizontal 水平 axis Vertical 垂直 axis Both 兩者 Drag to move 拖曳以移動 + Drag to move out of the set 拖曳以移出該組合 Drag to float as a toolbar 拖曳使其浮動為工具列 Put back in the toolbar 放回工具列 Float Panel 浮動面板 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 091fb5da3..5ab14588b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -260,7 +260,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel.float", "Float Panel", "", - "{panel: id or \"tools\", x?, y?, onto?: id, group?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it with the floating group holding that panel instead; `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved. \"tools\" floats the Tools panel. Returns {panel, floating, group, pos}", + "{panel: id or \"tools\", x?, y?, onto?: id, below?: id, above?: id, group?: bool, collapsed?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it as a tab with the floating group holding that panel instead; `below` / `above` puts its group in a set with that group, right below or above it, as dropping it on that group's bottom or top edge does (the set's groups stack top to bottom and move together); `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved (out of its set, given x, y); `collapsed` shows its group's tabs only, as a double-click on a tab does. \"tools\" floats the Tools panel. Returns {panel, floating, group, set, pos, collapsed}", ), ( "window.panel.dock", @@ -1126,6 +1126,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option): the set moves by the top +/// group's title bar, and a group dragged by its tab strip leaves it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct FloatingPanels { /// Panel ids (`window.panel`), in tab order. pub panels: Vec, @@ -446,6 +451,13 @@ pub struct FloatingPanels { /// A width the group was given (the Tabs panel sized over its text), else its panels' own. #[serde(default, skip_serializing_if = "Option::is_none")] pub width: Option, + /// The set this group is stacked in with others (the same number), top to bottom in list + /// order; none when it floats on its own. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub column: Option, + /// Collapsed to its tabs (a double-click on a tab), its panel hidden. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub collapsed: bool, } impl FloatingPanels { @@ -458,10 +470,35 @@ impl FloatingPanels { g.active = g.active.min(g.panels.len().saturating_sub(1)); !g.panels.is_empty() }); + Self::tidy(groups); if toolbar_pos.is_some_and(|p| !p.iter().all(|v| v.is_finite())) { *toolbar_pos = None; } } + + /// Keep each set's groups together in the list, after its first one and in the order they + /// come, at its first one's position; a set left with one group floats on its own. + pub fn tidy(groups: &mut Vec) { + let mut out: Vec = Vec::with_capacity(groups.len()); + for mut g in groups.drain(..) { + match g.column.and_then(|c| out.iter().rposition(|o| o.column == Some(c))) { + Some(i) => { + if let Some(first) = out.iter().find(|o| o.column == g.column) { + g.pos = first.pos; + } + out.insert(i + 1, g); + } + None => out.push(g), + } + } + let alone: Vec = out.iter().filter_map(|g| g.column).filter(|c| out.iter().filter(|g| g.column == Some(*c)).count() < 2).collect(); + for g in &mut out { + if g.column.is_some_and(|c| alone.contains(&c)) { + g.column = None; + } + } + *groups = out; + } } impl UiState { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8ad6a9674..c33bf1a85 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -260,13 +260,20 @@ Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own; dropped on another group's title bar or tabs it stacks with that group, and dropped on the dock (lit up while the -pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column. -`window.panel.float {panel, x?, y?, onto?, group?}` floats a panel (with `group`, its whole group) with its top-left -corner at `x`, `y` (window points; default cascaded), or stacks it with the floating group holding `onto`; +pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column. Dropped on another +group's bottom or top edge (a line shows where), a group joins it in a set: the groups stack top to bottom, each +showing one of its panels, and move together by the top group's title bar, whose × docks them all; a group dragged by +its tab strip (a lone panel's tab or the strip right of the tabs) leaves the set. A double-click on a tab collapses a +group to its tabs, and another expands it. +`window.panel.float {panel, x?, y?, onto?, below?, above?, group?, collapsed?}` floats a panel (with `group`, its whole +group) with its top-left corner at `x`, `y` (window points; default cascaded), or stacks it as a tab with the floating +group holding `onto`, or puts its group in a set right `below` or `above` the group holding that panel; `collapsed` +collapses or expands its group; `window.panel.dock {panel, group?}` puts it back. `panel` takes the `window.panel` ids and labels, and `"tools"` for the Tools panel, which floats by its title bar and docks on the window's left edge. `window.panel` on a floating -panel shows its tab. `ui.floating_panels` in `ui.inspect` lists the groups (panel ids, the index of the tab shown, -the top-left corner) and `ui.toolbar_pos` the Tools panel's corner (null: docked); both are saved with the +panel shows its tab (expanding its group). `ui.floating_panels` in `ui.inspect` lists the groups (panel ids, the +index of the tab shown, the top-left corner, `column`: the set it is stacked in, top to bottom in list order, and +`collapsed`) and `ui.toolbar_pos` the Tools panel's corner (null: docked); both are saved with the preferences and in user workspaces (the built-in workspaces dock everything), and a corner saved on a bigger window is clamped into this one when drawn. Floating panels stay inside the app window (no separate OS windows). From 77667945b176b2eb3b626644953fca8a78d2d9de Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Fri, 9 Oct 2026 18:30:34 +0000 Subject: [PATCH 60/97] UI: dock floating panel sets as dock columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A floating set dropped on the left edge of the icon column or of a dock column docks as a new dock column there, building a multi-column dock. Its groups stack, take tabs and tear out as in a floating set; the strip along its top floats the column again and its × docks its panels where they live. Popped-out icon panels open left of the dock columns. Columns are kept in preferences and workspaces; window.panel.dock takes column. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/ui-egui/src/dock.rs | 16 +- crates/ui-egui/src/floating.rs | 479 ++++++++++++++++++++++++++------- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/state.rs | 16 +- docs/control-protocol.md | 13 +- 10 files changed, 433 insertions(+), 100 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index a202ba76f..899d56c47 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1,5 +1,6 @@ //! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column -//! whose panels pop out to the left. The double arrow at the top of the dock collapses the tabbed +//! whose panels pop out to the left, and left of it the floating sets docked as columns +//! ([`floating::docked_columns`]). The double arrow at the top of the dock collapses the tabbed //! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column //! then expands it again. @@ -23,6 +24,12 @@ fn column_left_id() -> egui::Id { egui::Id::new("dock-icon-column-left") } +/// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's +/// (egui temp memory, one frame old). Popped-out panels open left of it. +fn dock_left_id() -> egui::Id { + egui::Id::new("dock-left") +} + /// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while /// one of its panels is popped out shows that panel's tab instead. pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) { @@ -148,7 +155,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); let column = column.response.rect; let dock = group.map_or(column, |g| g.union(column)); + // Floating sets docked as columns, left of the icon column. + let left = floating::docked_columns(app, ui).iter().map(|r| r.left()).fold(column.left(), f32::min); ui.ctx().data_mut(|d| { + d.insert_temp(dock_left_id(), left); d.insert_temp(column_left_id(), column.left()); d.insert_temp(floating::dock_rect_id(), dock); d.insert_temp(floating::icons_rect_id(), column); @@ -226,7 +236,9 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { } else { screen.right() }; - let right = column - 6.0; + let left = ctx.data(|d| d.get_temp::(dock_left_id())).filter(|x| x.is_finite() && *x > screen.left() && *x <= column).unwrap_or(column); + let right = if app.ui.dock && app.ui.screen_mode < 3 { left } else { column }; + let right = right - 6.0; // The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one // that keeps the flyout on screen. let width = panel_width(id); diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index f0edd063d..ed2cefdf8 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -73,6 +73,8 @@ enum Drop { Below(&'static str), /// In a set with the floating group holding this panel, above it (the top group of its set). Above(&'static str), + /// In the dock as a new column at this place (1: next to the icon column, counting left). + Column(u32), } fn move_id() -> Id { @@ -158,7 +160,7 @@ pub fn attach(ui: &mut UiState, moved: &[usize], onto: &str, below: bool) { Some(c) => c, None => ui.floating_panels.iter().filter_map(|g| g.column).max().map_or(1, |c| c.saturating_add(1)), }; - let Some(pos) = ui.floating_panels.get(target).map(|g| g.pos) else { return }; + let Some((pos, docked)) = ui.floating_panels.get(target).map(|g| (g.pos, g.docked)) else { return }; let (mut taken, mut kept) = (vec![], vec![]); for (i, mut g) in ui.floating_panels.drain(..).enumerate() { if i == target { @@ -167,6 +169,7 @@ pub fn attach(ui: &mut UiState, moved: &[usize], onto: &str, below: bool) { if moved.contains(&i) { g.column = Some(column); g.pos = pos; + g.docked = docked; taken.push(g); } else { kept.push(g); @@ -302,6 +305,18 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2 let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) }; let own = group_of(&app.ui, id); let moving = own.map(|i| set_of(&app.ui, i)).unwrap_or_default(); + // The left edge of the icon column or of a dock column docks the moved groups as a new column + // there. + if app.ui.dock && app.ui.screen_mode < 3 { + let icons = ctx.data(|d| d.get_temp::(icons_rect_id())); + let columns = ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default(); + for (k, r) in icons.into_iter().chain(columns).enumerate() { + if Rect::from_x_y_ranges((r.left() - EDGE - 3.0)..=(r.left() + EDGE - 1.0), r.y_range()).contains(at) { + let line = Rect::from_x_y_ranges((r.left() - 2.0)..=(r.left() + 2.0), r.y_range()); + return Some((Drop::Column(u32::try_from(k + 1).unwrap_or(u32::MAX)), line)); + } + } + } // Another group's top or bottom edge puts the moved groups in a set with it; its title bar and // tabs stack them as tabs. for (i, g) in app.ui.floating_panels.iter().enumerate() { @@ -365,7 +380,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { } } match target { - Some((Drop::Below(_) | Drop::Above(_), line)) => insertion_line(ctx, line), + Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line), Some((_, zone)) => highlight(ctx, zone), None => {} } @@ -393,10 +408,11 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { Drop::Stack(onto) => stack(&mut app.ui, &ids, active, onto), Drop::Below(onto) => attach(&mut app.ui, &set, onto, true), Drop::Above(onto) => attach(&mut app.ui, &set, onto, false), + Drop::Column(slot) => dock_column(&mut app.ui, &set, slot), } } -/// What a press on a floating group did. +/// What a press on a floating or docked group did. enum Action { /// Show the group's `k`th tab. Activate(usize, usize), @@ -410,6 +426,8 @@ enum Action { Detach(usize, f32), /// Float this panel out of its group; its tab began this far right. Tear(&'static str, f32), + /// Float the docked column of this group, its top-left corner this far from the pointer. + Undock(usize, Vec2), } /// A floating group as drawn: its index, panels, the one shown and whether it's collapsed. @@ -420,8 +438,25 @@ struct Shown { collapsed: bool, } +/// The groups `set` as drawn, top to bottom. +fn shown_groups(ui: &UiState, set: &[usize]) -> Vec { + set.iter() + .filter_map(|&gi| { + let g = ui.floating_panels.get(gi)?; + let ids = ids_of(g); + let active = ids.get(g.active).or(ids.first()).copied()?; + Some(Shown { gi, ids, active, collapsed: g.collapsed }) + }) + .collect() +} + +/// The groups `set` heads are docked as a dock column. +fn docked(ui: &UiState, set: &[usize]) -> bool { + set.first().and_then(|&i| ui.floating_panels.get(i)).is_some_and(|g| g.docked.is_some()) +} + /// The floating groups, each a stack of tabs, stacked in their sets under a title bar. Hidden with -/// the dock (Tab, Presentation Mode). +/// the dock (Tab, Presentation Mode). Sets docked as dock columns are drawn by [`docked_columns`]. pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 { return; @@ -430,9 +465,16 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { for gi in 0..app.ui.floating_panels.len() { if leads(&app.ui, gi) { let set = set_of(&app.ui, gi); - show_set(app, ctx, &set, &mut action); + if !docked(&app.ui, &set) { + show_set(app, ctx, &set, &mut action); + } } } + apply(app, ctx, action); +} + +/// Carry out what a press on a floating or docked group did. +fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) { match action { Some(Action::Activate(gi, k)) => { if let Some(g) = app.ui.floating_panels.get_mut(gi) @@ -463,12 +505,25 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } Some(Action::Detach(gi, left)) => detach(app, ctx, gi, left), Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)), + Some(Action::Undock(gi, grab)) => { + let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + let Some(id) = set_of(&app.ui, gi).first().and_then(|&i| shown(&app.ui, i)) else { return }; + let p = at - grab; + for i in set_of(&app.ui, gi) { + if let Some(g) = app.ui.floating_panels.get_mut(i) { + g.docked = None; + g.pos = [p.x, p.y]; + } + } + FloatingPanels::tidy(&mut app.ui.floating_panels); + start(ctx, Moving::Panel(id), grab); + } None => {} } } -/// Take group `gi` out of its set under the pointer, its tab strip having begun `left` points from -/// the window's left, and carry on moving it with the pointer. +/// Take group `gi` out of its set (or its dock column) under the pointer, its tab strip having +/// begun `left` points from the window's left, and carry on moving it with the pointer. fn detach(app: &mut VectorcraftApp, ctx: &egui::Context, gi: usize, left: f32) { let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; let Some(id) = shown(&app.ui, gi) else { return }; @@ -476,33 +531,109 @@ fn detach(app: &mut VectorcraftApp, ctx: &egui::Context, gi: usize, left: f32) { let p = at - grab; if let Some(g) = app.ui.floating_panels.get_mut(gi) { g.column = None; + g.docked = None; g.pos = [p.x, p.y]; } FloatingPanels::tidy(&mut app.ui.floating_panels); start(ctx, Moving::Panel(id), grab); } +/// Put the groups `set` in the dock as a new column at `slot` (1: next to the icon column, counting +/// left), moving the columns from there one further left. +pub fn dock_column(ui: &mut UiState, set: &[usize], slot: u32) { + let slot = slot.max(1); + for (i, g) in ui.floating_panels.iter_mut().enumerate() { + if set.contains(&i) { + g.docked = Some(slot); + } else if let Some(d) = g.docked.filter(|d| *d >= slot) { + g.docked = Some(d.saturating_add(1)); + } + } + FloatingPanels::tidy(&mut ui.floating_panels); + if let Some(tab) = shown_tab(ui) { + ui.dock_tab = tab; + } +} + +/// The dock columns, as last drawn (egui temp memory), from the one next to the icon column +/// leftwards. +pub(crate) fn columns_id() -> Id { + Id::new("dock-columns") +} + +/// The sets docked as columns, by their column number (next to the icon column first). +fn docked_sets(ui: &UiState) -> Vec<(u32, Vec)> { + let mut sets: Vec<(u32, Vec)> = (0..ui.floating_panels.len()) + .filter(|&gi| leads(ui, gi)) + .filter_map(|gi| Some((ui.floating_panels.get(gi)?.docked?, set_of(ui, gi)))) + .collect(); + sets.sort_by_key(|(d, _)| *d); + sets +} + +/// The sets docked as columns, each a column left of the icon column (`ui`, the window's root, as +/// [`dock::show`] lays it out): a strip along its top that floats the column when dragged and +/// whose × puts its panels back in the dock, then its groups top to bottom. Returns the columns' +/// bounds, next to the icon column first. +pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec { + let t = Tokens::get(ui.ctx()); + let mut rects = vec![]; + let mut action = None; + for (_, set) in docked_sets(&app.ui) { + let groups = shown_groups(&app.ui, &set); + let Some(first) = groups.first().and_then(|g| g.ids.first().copied()) else { continue }; + let natural = groups.iter().flat_map(|g| g.ids.iter()).map(|id| dock::panel_width(id)).fold(200.0, f32::max); + let panel = egui::Panel::right(Id::new(("dock-column", first))) + .resizable(true) + .default_size(natural) + .size_range(200.0..=520.0) + .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border))) + .show(ui, |ui| { + ui.spacing_mut().item_spacing = Vec2::ZERO; + let width = ui.available_width(); + let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover()); + ui.painter().rect_filled(bar, 0.0, t.tab_strip); + let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE)); + let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("column-bar"), Sense::drag()); + for k in 0..2 { + let y = bar.center().y - 1.5 + k as f32 * 3.0; + ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled)); + } + if grip.drag_started() + && let (Some(at), Some(&gi)) = (ui.input(|i| i.pointer.press_origin()), set.first()) + { + action = Some(Action::Undock(gi, at - bar.min)); + } + grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group")); + let x = ui.interact(close, ui.id().with("column-close"), Sense::click()); + icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim }); + if x.on_hover_text(tl!("Put back in the dock")).clicked() { + action = Some(Action::Close(set.clone())); + } + let n = groups.len() as f32; + let open = groups.iter().filter(|g| !g.collapsed).count().max(1) as f32; + let tall = ((ui.available_height() - n * STRIP - (n - 1.0) * GAP - 24.0 * open) / open).max(80.0); + draw_groups(app, ui, &groups, width, tall, bar.top(), None, &mut action); + }); + rects.push(panel.response.rect); + } + ui.ctx().data_mut(|d| d.insert_temp(columns_id(), rects.clone())); + let ctx = ui.ctx().clone(); + apply(app, &ctx, action); + rects +} + /// Draw the set of floating groups `set` (top to bottom) as one floating box. fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action: &mut Option) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); - let pointer = ctx.input(|i| i.pointer.interact_pos()); - let groups: Vec = set - .iter() - .filter_map(|&gi| { - let g = app.ui.floating_panels.get(gi)?; - let ids = ids_of(g); - let active = ids.get(g.active).or(ids.first()).copied()?; - Some(Shown { gi, ids, active, collapsed: g.collapsed }) - }) - .collect(); + let groups = shown_groups(&app.ui, set); let Some((lead, first)) = groups.first().and_then(|g| Some((g, *g.ids.first()?))) else { return }; let Some(saved) = app.ui.floating_panels.get(lead.gi).map(|g| g.pos) else { return }; let given = set.iter().filter_map(|&i| app.ui.floating_panels.get(i)?.width).reduce(f32::max).map(|w| w.min(screen.width() - 16.0)); let area = area_id(first); let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(lead.active), 200.0), |r| r.size()); let pos = clamp(saved, size, screen); - let alone = groups.len() == 1; // The tabbed group's panels fill (Layers) or scroll (Properties) to near the window's bottom, // leaving room for their margins (a group that only just fitted would creep up a little each // frame); the open panels of a set share that height. @@ -514,24 +645,17 @@ fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action let frame = egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO); frame.show(ui, |ui| { ui.spacing_mut().item_spacing = Vec2::ZERO; - let tabs: Vec> = groups + let width = groups .iter() .map(|g| { - g.ids + let tabs_width: f32 = g + .ids .iter() .map(|id| { let label = panel(id).map_or(*id, |(_, label)| label); - let color = if *id == g.active { t.text_strong } else { t.text_dim }; - ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color) + ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0 }) - .collect() - }) - .collect(); - let width = groups - .iter() - .zip(&tabs) - .map(|(g, tabs)| { - let tabs_width: f32 = tabs.iter().map(|tab| tab.size().x + 24.0).sum(); + .sum(); g.ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max) }) .fold(given.unwrap_or(0.0), f32::max); @@ -554,65 +678,93 @@ fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action if x.on_hover_text(tl!("Put back in the dock")).clicked() { *action = Some(Action::Close(set.to_vec())); } - for (k, (g, tabs)) in groups.iter().zip(tabs).enumerate() { - let top = if k == 0 { - bar.top() - } else { - let (gap, _) = ui.allocate_exact_size(vec2(width, GAP), Sense::hover()); - ui.painter().rect_filled(gap, 0.0, t.tab_strip); - gap.top() - }; - // Tab strip: a click shows a tab, a double-click collapses the group, a drag out of - // the strip floats a tab on its own; a lone tab or the strip right of the tabs moves - // the group (out of its set). - let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.panel_darker); - let lone = g.ids.len() == 1; - let mut left = strip.left(); - for (j, (&id, galley)) in g.ids.iter().zip(tabs).enumerate() { - let r = Rect::from_min_size(pos2(left, strip.top()), vec2(galley.size().x + 24.0, STRIP)); - left = r.right(); - let resp = ui.interact(r, ui.id().with(("tab", id)), Sense::click_and_drag()); - if id == g.active && !g.collapsed { - ui.painter().rect_filled(r, 0.0, t.panel); - } - ui.painter().galley(pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); - if resp.double_clicked() { - *action = Some(Action::Collapse(g.gi)); - } else if resp.clicked() { - *action = Some(Action::Activate(g.gi, j)); - } else if lone && alone && resp.drag_started() { - *action = Some(Action::Move(id, pos)); - } else if lone && resp.drag_started() { - *action = Some(Action::Detach(g.gi, strip.left())); - } else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) { - *action = Some(Action::Tear(id, r.left())); - } - } - let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); - let rest = Rect::from_min_max(pos2(left, strip.top()), pos2(menu.left() - 4.0, strip.bottom())); - if rest.width() > 0.0 { - let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag()); - if resp.drag_started() { - *action = Some(if alone { Action::Move(g.active, pos) } else { Action::Detach(g.gi, strip.left()) }); - } - let tip = if alone { tl!("Drag to move") } else { tl!("Drag to move out of the set") }; - resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip); - } - panels::panel_menu(app, ui, g.active, menu); - if !g.collapsed { - dock::panel_body(app, ui, g.active, width, tall); - } - let bottom = ui.min_rect().bottom().max(strip.bottom()); - if let Some(&id) = g.ids.first() { - let rect = Rect::from_min_max(pos2(strip.left(), top), pos2(strip.left() + width, bottom)); - ui.ctx().data_mut(|d| d.insert_temp(group_rect_id(id), rect)); - } - } + let alone = (groups.len() == 1).then_some(pos); + draw_groups(app, ui, &groups, width, tall, bar.top(), alone, action); }); }); } +/// Draw `groups` top to bottom, `width` wide, their open panels `tall`, the first one's bar having +/// begun at `top`: a bar between groups, each group's tab strip, then its shown panel. `alone`, a +/// lone floating group drawn at that corner: its tab strip moves it (else it takes its group out of +/// the set or dock column). +#[allow(clippy::too_many_arguments)] +fn draw_groups( + app: &mut VectorcraftApp, + ui: &mut egui::Ui, + groups: &[Shown], + width: f32, + tall: f32, + top: f32, + alone: Option, + action: &mut Option, +) { + let t = Tokens::get(ui.ctx()); + let pointer = ui.input(|i| i.pointer.interact_pos()); + for (k, g) in groups.iter().enumerate() { + let top = if k == 0 { + top + } else { + let (gap, _) = ui.allocate_exact_size(vec2(width, GAP), Sense::hover()); + ui.painter().rect_filled(gap, 0.0, t.tab_strip); + gap.top() + }; + // Tab strip: a click shows a tab, a double-click collapses the group, a drag out of the + // strip floats a tab on its own; a lone tab or the strip right of the tabs moves the group + // (out of its set). + let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.panel_darker); + let lone = g.ids.len() == 1; + let mut left = strip.left(); + for (j, &id) in g.ids.iter().enumerate() { + let label = panel(id).map_or(id, |(_, label)| label); + let color = if id == g.active { t.text_strong } else { t.text_dim }; + let galley = ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color); + let r = Rect::from_min_size(pos2(left, strip.top()), vec2(galley.size().x + 24.0, STRIP)); + left = r.right(); + let resp = ui.interact(r, ui.id().with(("tab", id)), Sense::click_and_drag()); + if id == g.active && !g.collapsed { + ui.painter().rect_filled(r, 0.0, t.panel); + } + ui.painter().galley(pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); + if resp.double_clicked() { + *action = Some(Action::Collapse(g.gi)); + } else if resp.clicked() { + *action = Some(Action::Activate(g.gi, j)); + } else if lone && resp.drag_started() { + *action = Some(match alone { + Some(pos) => Action::Move(id, pos), + None => Action::Detach(g.gi, strip.left()), + }); + } else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) { + *action = Some(Action::Tear(id, r.left())); + } + } + let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); + let rest = Rect::from_min_max(pos2(left, strip.top()), pos2(menu.left() - 4.0, strip.bottom())); + if rest.width() > 0.0 { + let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag()); + if resp.drag_started() { + *action = Some(match alone { + Some(pos) => Action::Move(g.active, pos), + None => Action::Detach(g.gi, strip.left()), + }); + } + let tip = if alone.is_some() { tl!("Drag to move") } else { tl!("Drag to move out of the set") }; + resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip); + } + panels::panel_menu(app, ui, g.active, menu); + if !g.collapsed { + dock::panel_body(app, ui, g.active, width, tall); + } + let bottom = ui.min_rect().bottom().max(strip.bottom()); + if let Some(&id) = g.ids.first() { + let rect = Rect::from_min_max(pos2(strip.left(), top), pos2(strip.left() + width, bottom)); + ui.ctx().data_mut(|d| d.insert_temp(group_rect_id(id), rect)); + } + } +} + /// `window.panel.float` and `window.panel.dock`; `None` for other commands. pub fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { let float = match id { @@ -660,18 +812,41 @@ fn float_or_dock(app: &mut VectorcraftApp, float_it: bool, p: &Value) -> Result< if [onto, below, above].iter().filter(|o| o.is_some()).count() > 1 { return Err("give only one of onto, below and above".into()); } + let slot = column_slot(&app.ui, p)?; app.ui.dock = true; + // Its own group already (not a panel taken out of its group). + let whole = own.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.panels.len() == ids.len())); if !float_it { - dock(&mut app.ui, &ids, id); - return Ok(json!({ "panel": id, "floating": false })); + let Some(slot) = slot else { + dock(&mut app.ui, &ids, id); + return Ok(json!({ "panel": id, "floating": false })); + }; + // A dock column of its own group (floated out of its group first) and the rest of its set. + if whole.is_none() { + float(&mut app.ui, &ids, id, Pos2::ZERO); + } + if let Some(gi) = group_of(&app.ui, id) { + let set = set_of(&app.ui, gi); + dock_column(&mut app.ui, &set, slot); + } + let column = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)).and_then(|g| g.docked); + return Ok(json!({ "panel": id, "floating": false, "column": column })); + } + // Floating a docked group floats its dock column. + if let Some(gi) = whole.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.docked.is_some())) { + for i in set_of(&app.ui, gi) { + if let Some(g) = app.ui.floating_panels.get_mut(i) { + g.docked = None; + } + } + FloatingPanels::tidy(&mut app.ui.floating_panels); } if let Some((next, under)) = below.map(|b| (b, true)).or(above.map(|a| (a, false))) { let next = crate::menus::normalize_panel(next) .filter(|o| !ids.contains(o) && group_of(&app.ui, o).is_some()) .ok_or("below and above must name a panel floating in another group")?; // The panels as a group of their own (their group already, or floated out), in the set. - let own = own.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.panels.len() == ids.len())); - if own.is_none() { + if whole.is_none() { float(&mut app.ui, &ids, id, Pos2::ZERO); } if let Some(gi) = group_of(&app.ui, id) { @@ -690,8 +865,23 @@ fn float_or_dock(app: &mut VectorcraftApp, float_it: bool, p: &Value) -> Result< let g = gi.and_then(|i| app.ui.floating_panels.get(i)); let set: Vec<&Vec> = gi.map(|i| set_of(&app.ui, i)).unwrap_or_default().iter().filter_map(|&i| app.ui.floating_panels.get(i)).map(|g| &g.panels).collect(); - let (group, pos, collapsed) = (g.map(|g| &g.panels), g.map(|g| g.pos), g.is_some_and(|g| g.collapsed)); - Ok(json!({ "panel": id, "floating": true, "group": group, "set": set, "pos": pos, "collapsed": collapsed })) + let (group, pos, collapsed, column) = (g.map(|g| &g.panels), g.map(|g| g.pos), g.is_some_and(|g| g.collapsed), g.and_then(|g| g.docked)); + Ok(json!({ "panel": id, "floating": column.is_none(), "group": group, "set": set, "pos": pos, "collapsed": collapsed, "column": column })) +} + +/// The `column` param of `window.panel.dock`: `true` for a new dock column at the dock's left, or +/// the place of the new column (1: next to the icon column, counting left); none to dock the panel +/// where it lives. +fn column_slot(ui: &UiState, p: &Value) -> Result, String> { + let next = ui.floating_panels.iter().filter_map(|g| g.docked).max().unwrap_or(0).saturating_add(1); + match p.get("column") { + None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None), + Some(Value::Bool(true)) => Ok(Some(next)), + Some(v) => match v.as_u64().filter(|n| *n >= 1) { + Some(n) => Ok(Some(u32::try_from(n).unwrap_or(u32::MAX).min(next))), + None => Err("column must be true or a number from 1".into()), + }, + } } /// Float `ids` (`id` among them; `own`, the group holding `id`) as their own group at `at`, or @@ -718,6 +908,7 @@ fn float_onto( if let Some(at) = at { g.pos = [at.x, at.y]; g.column = None; + g.docked = None; FloatingPanels::tidy(&mut app.ui.floating_panels); } } @@ -1199,6 +1390,112 @@ mod tests { assert_eq!(edited.floating_panels[1].pos, [1.0, 2.0]); } + /// The dock columns as drawn, next to the icon column first. + fn columns(h: &Harness) -> Vec { + h.ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default() + } + + /// Each group's panels and dock column, in list order. + fn docked_of(groups: &[FloatingPanels]) -> Vec<(Vec<&str>, Option)> { + groups.iter().map(|g| (g.panels.iter().map(String::as_str).collect(), g.docked)).collect() + } + + #[test] + fn sets_dropped_on_the_docks_left_edge_become_dock_columns() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "color", "x": 300, "y": 150})).unwrap(); + h.settle(); + // Held over the icon column's left edge, a line shows the new column; dropped, it docks. + let icons = h.temp_rect(icons_rect_id()); + let edge = pos2(icons.left() - 2.0, icons.center().y); + h.hold(h.title("color"), edge); + let target = drop_target(&h.app, &h.ctx, Moving::Panel("color"), edge); + assert_eq!(target.map(|t| t.0), Some(Drop::Column(1))); + assert!(target.is_some_and(|t| t.1.width() <= 4.0), "a line: {target:?}"); + h.release(edge); + assert_eq!(docked_of(&h.app.ui.floating_panels), [(vec!["color"], Some(1))]); + let cols = columns(&h); + assert_eq!(cols.len(), 1); + let icons = h.temp_rect(icons_rect_id()); + assert!((cols[0].right() - icons.left()).abs() < 2.0, "right of it the icon column: {cols:?} {icons:?}"); + // Drawn in the column, not floating too (a floating box, drawn after the dock, would move it). + assert!(cols[0].contains_rect(h.temp_rect(group_rect_id("color")).shrink(1.0)), "Color in the column"); + // Swatches dropped on the docked Color's bottom edge joins its column. + h.app.run("window.panel.float", json!({"panel": "swatches", "x": 300, "y": 300})).unwrap(); + h.settle(); + let color = h.temp_rect(group_rect_id("color")); + let below = pos2(color.center().x, color.bottom() + 2.0); + h.drag(h.title("swatches"), below); + assert_eq!(docked_of(&h.app.ui.floating_panels), [(vec!["color"], Some(1)), (vec!["swatches"], Some(1))]); + assert!(columns(&h)[0].contains_rect(h.temp_rect(group_rect_id("swatches")).shrink(1.0))); + // Stroke dropped on the column's left edge makes a second column, left of it. + h.app.run("window.panel.float", json!({"panel": "stroke", "x": 300, "y": 400})).unwrap(); + h.settle(); + let col = columns(&h)[0]; + h.drag(h.title("stroke"), pos2(col.left() - 2.0, col.center().y)); + assert_eq!(docked_of(&h.app.ui.floating_panels).last(), Some(&(vec!["stroke"], Some(2)))); + let cols = columns(&h); + assert!(cols.len() == 2 && cols[1].right() <= cols[0].left() + 2.0, "{cols:?}"); + // A docked lone tab dragged away floats its group, leaving the rest of the column docked. + let swatches = h.temp_rect(group_rect_id("swatches")); + h.drag(pos2(swatches.left() + 20.0, swatches.top() + GAP + STRIP / 2.0), pos2(500.0, 500.0)); + let d = docked_of(&h.app.ui.floating_panels); + assert!(d.contains(&(vec!["swatches"], None)) && d.contains(&(vec!["color"], Some(1))), "{d:?}"); + // The column's top strip floats the whole column. + let col = columns(&h)[0]; + h.drag(pos2(col.left() + 30.0, col.top() + TITLE / 2.0), pos2(500.0, 200.0)); + let d = docked_of(&h.app.ui.floating_panels); + assert!(d.contains(&(vec!["color"], None)) && d.contains(&(vec!["stroke"], Some(1))), "renumbered: {d:?}"); + assert!(h.group("color").contains(pos2(500.0, 200.0)), "under the pointer"); + // The column's × puts its panels back in the dock. + let col = columns(&h)[0]; + h.click(pos2(col.right() - 11.0, col.top() + TITLE / 2.0)); + assert!(!h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"stroke".to_string()))); + assert!(columns(&h).is_empty()); + } + + #[test] + fn dock_columns_by_command_are_checked_and_kept_in_workspaces() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + assert!(app.run("window.panel.dock", json!({"panel": "color", "column": 0})).is_err()); + assert!(app.run("window.panel.dock", json!({"panel": "color", "column": "left"})).is_err()); + assert!(app.ui.floating_panels.is_empty()); + let r = app.run("window.panel.dock", json!({"panel": "color", "column": true})).unwrap(); + assert_eq!((r["column"].clone(), r["floating"].clone()), (json!(1), json!(false))); + app.run("window.panel.dock", json!({"panel": "layers", "column": true})).unwrap(); + // Next to the icon column, moving Color and Layers one column left. + app.run("window.panel.dock", json!({"panel": "stroke", "column": 1})).unwrap(); + app.run("window.panel.float", json!({"panel": "gradient", "below": "stroke"})).unwrap(); + let d = docked_of(&app.ui.floating_panels); + for (panel, column) in [("stroke", 1), ("gradient", 1), ("color", 2), ("layers", 3)] { + assert!(d.contains(&(vec![panel], Some(column))), "{panel} in column {column}: {d:?}"); + } + // Kept in a workspace and the preferences. + app.run("window.workspace.new", json!({"name": "Columns"})).unwrap(); + let saved = app.ui.floating_panels.clone(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + app.run("window.workspace", json!({"name": "Columns"})).unwrap(); + assert_eq!(app.ui.floating_panels, saved); + let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(prefs.sanitized().floating_panels, saved); + // Floated, Color's column leaves the dock and the columns close up. + let r = app.run("window.panel.float", json!({"panel": "color"})).unwrap(); + assert_eq!((r["floating"].clone(), r["column"].clone()), (json!(true), json!(null))); + assert!(docked_of(&app.ui.floating_panels).contains(&(vec!["layers"], Some(2)))); + // A hand-edited file: columns numbered without gaps, a set's groups in its first one's. + let edited: UiState = serde_json::from_value(json!({ + "floating_panels": [ + {"panels": ["layers"], "pos": [1, 2], "docked": 7}, + {"panels": ["color"], "pos": [1, 2], "column": 3, "docked": 3}, + {"panels": ["stroke"], "pos": [5, 6], "column": 3}, + ], + })) + .unwrap(); + let edited = edited.sanitized(); + let d = docked_of(&edited.floating_panels); + assert_eq!(d, [(vec!["layers"], Some(2)), (vec!["color"], Some(1)), (vec!["stroke"], Some(1))]); + } + fn floating_pos(app: &VectorcraftApp, id: &str) -> [f32; 2] { group_of(&app.ui, id).map(|i| app.ui.floating_panels[i].pos).unwrap() } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7d5beda50..22b4dc9b7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3876,6 +3876,7 @@ axis Both Ambos @msg onto must name a panel floating in another group onto debe nombrar un panel que flote en otro grupo @msg below and above must name a panel floating in another group below y above deben nombrar un panel que flote en otro grupo @msg give only one of onto, below and above indica solo uno de onto, below y above +@msg column must be true or a number from 1 column debe ser true o un número desde 1 @msg x and y must both be numbers x e y deben ser números @msg "{0}" is not a colour: write it as #rrggbb "{0}" no es un color: escríbelo como #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead esto no es un logotipo de colores planos ({_1}% de sus zonas sólidas coinciden con unos pocos colores planos), así que el modo Logotipo plano convertiría sus degradados en manchas: usa el modo Color diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 00d33e735..0c4c1467d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3873,6 +3873,7 @@ axis Both Les deux @msg onto must name a panel floating in another group onto doit désigner un panneau flottant dans un autre groupe @msg below and above must name a panel floating in another group below et above doivent désigner un panneau flottant dans un autre groupe @msg give only one of onto, below and above indiquez un seul de onto, below et above +@msg column must be true or a number from 1 column doit être true ou un nombre à partir de 1 @msg x and y must both be numbers x et y doivent tous deux être des nombres @msg "{0}" is not a colour: write it as #rrggbb « {0} » n’est pas une couleur : écrivez-la sous la forme #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ce n’est pas un logo en aplats ({_1} % de ses zones pleines correspondent à quelques couleurs unies), donc le mode Logo en aplats transformerait ses dégradés en taches : utilisez le mode Couleur diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c5b53a789..070b9318f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3876,6 +3876,7 @@ axis Both Entrambi @msg onto must name a panel floating in another group onto deve indicare un pannello mobile in un altro gruppo @msg below and above must name a panel floating in another group below e above devono indicare un pannello mobile in un altro gruppo @msg give only one of onto, below and above indica solo uno tra onto, below e above +@msg column must be true or a number from 1 column deve essere true o un numero da 1 @msg x and y must both be numbers x e y devono essere entrambi numeri @msg "{0}" is not a colour: write it as #rrggbb "{0}" non è un colore: scrivilo come #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead questo non è un logo a colori piatti ({_1}% delle sue aree piene corrisponde a pochi colori piatti), quindi la modalità Logo piatto trasformerebbe i suoi sfumati in macchie: usa la modalità Colore diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e9642d731..b950958f4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3872,6 +3872,7 @@ axis Both Оба @msg onto must name a panel floating in another group onto должен называть панель, плавающую в другой группе @msg below and above must name a panel floating in another group below и above должны называть панель, плавающую в другой группе @msg give only one of onto, below and above укажите только один из onto, below и above +@msg column must be true or a number from 1 column должен быть true или числом от 1 @msg x and y must both be numbers x и y должны быть числами @msg "{0}" is not a colour: write it as #rrggbb «{0}» не является цветом: запишите его как #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead это не логотип из плоских цветов ({_1}% его сплошных областей совпадает с несколькими плоскими цветами), поэтому режим «Плоский логотип» превратил бы градиенты в пятна: используйте режим «Цвет» diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5ecd32f36..8171d8902 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3859,6 +3859,7 @@ axis Both Обидві @msg onto must name a panel floating in another group onto має називати панель, що плаває в іншій групі @msg below and above must name a panel floating in another group below і above мають називати панель, що плаває в іншій групі @msg give only one of onto, below and above вкажіть лише один із onto, below і above +@msg column must be true or a number from 1 column має бути true або числом від 1 @msg x and y must both be numbers x і y мають бути числами @msg "{0}" is not a colour: write it as #rrggbb "{0}" не є кольором: запишіть його як #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead це не логотип із плоских кольорів ({_1}% його суцільних областей збігається з кількома плоскими кольорами), тому режим «Плоский логотип» перетворив би градієнти на плями: використовуйте режим «Колір» diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5ab14588b..18ee497d0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -260,13 +260,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel.float", "Float Panel", "", - "{panel: id or \"tools\", x?, y?, onto?: id, below?: id, above?: id, group?: bool, collapsed?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it as a tab with the floating group holding that panel instead; `below` / `above` puts its group in a set with that group, right below or above it, as dropping it on that group's bottom or top edge does (the set's groups stack top to bottom and move together); `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved (out of its set, given x, y); `collapsed` shows its group's tabs only, as a double-click on a tab does. \"tools\" floats the Tools panel. Returns {panel, floating, group, set, pos, collapsed}", + "{panel: id or \"tools\", x?, y?, onto?: id, below?: id, above?: id, group?: bool, collapsed?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it as a tab with the floating group holding that panel instead; `below` / `above` puts its group in a set with that group, right below or above it, as dropping it on that group's bottom or top edge does (the set's groups stack top to bottom and move together); `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved (out of its set, given x, y); `collapsed` shows its group's tabs only, as a double-click on a tab does; a docked group floats its dock column. \"tools\" floats the Tools panel. Returns {panel, floating, group, set, pos, collapsed, column}", ), ( "window.panel.dock", "Dock Panel", "", - "{panel: id or \"tools\", group?: bool} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; \"tools\" docks the Tools panel at the window's left edge", + "{panel: id or \"tools\", group?: bool, column?: true or n} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge. Returns {panel, floating, column}", ), ( "window.collapseDock", diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ec83303a8..56038265f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -458,6 +458,10 @@ pub struct FloatingPanels { /// Collapsed to its tabs (a double-click on a tab), its panel hidden. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub collapsed: bool, + /// Docked as a column of the dock (with the rest of its set) instead of floating: the columns + /// stand left of the icon column, 1 next to it, counting left. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docked: Option, } impl FloatingPanels { @@ -477,7 +481,8 @@ impl FloatingPanels { } /// Keep each set's groups together in the list, after its first one and in the order they - /// come, at its first one's position; a set left with one group floats on its own. + /// come, at its first one's position and in its dock column; a set left with one group floats + /// (or stays docked) on its own; the dock columns are numbered 1, 2… without gaps. pub fn tidy(groups: &mut Vec) { let mut out: Vec = Vec::with_capacity(groups.len()); for mut g in groups.drain(..) { @@ -485,6 +490,7 @@ impl FloatingPanels { Some(i) => { if let Some(first) = out.iter().find(|o| o.column == g.column) { g.pos = first.pos; + g.docked = first.docked; } out.insert(i + 1, g); } @@ -497,6 +503,14 @@ impl FloatingPanels { g.column = None; } } + let mut numbers: Vec = out.iter().filter_map(|g| g.docked).collect(); + numbers.sort_unstable(); + numbers.dedup(); + for g in &mut out { + if let Some(d) = g.docked { + g.docked = numbers.iter().position(|n| *n == d).and_then(|k| u32::try_from(k + 1).ok()); + } + } *groups = out; } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c33bf1a85..71b5cfa85 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -264,16 +264,21 @@ pointer is over it) or closed with its × its panels go back to the tabbed group group's bottom or top edge (a line shows where), a group joins it in a set: the groups stack top to bottom, each showing one of its panels, and move together by the top group's title bar, whose × docks them all; a group dragged by its tab strip (a lone panel's tab or the strip right of the tabs) leaves the set. A double-click on a tab collapses a -group to its tabs, and another expands it. +group to its tabs, and another expands it. Dropped on the left edge of the icon column or of a dock column, a set +docks as a new dock column there: its groups stack in it as in a floating set, take groups dropped on their edges and +tabs, and leave it dragged by their tab strip; the strip along the column's top floats the whole column again, and +its × puts its panels back where they live. `window.panel.float {panel, x?, y?, onto?, below?, above?, group?, collapsed?}` floats a panel (with `group`, its whole group) with its top-left corner at `x`, `y` (window points; default cascaded), or stacks it as a tab with the floating group holding `onto`, or puts its group in a set right `below` or `above` the group holding that panel; `collapsed` collapses or expands its group; -`window.panel.dock {panel, group?}` puts it back. `panel` takes the `window.panel` ids and labels, and `"tools"` for +`window.panel.dock {panel, group?, column?}` puts it back, or with `column` (`true`, or the place: 1 next to the icon +column, counting left) docks its group and set as a dock column; `window.panel.float` on a docked panel floats its +column. `panel` takes the `window.panel` ids and labels, and `"tools"` for the Tools panel, which floats by its title bar and docks on the window's left edge. `window.panel` on a floating panel shows its tab (expanding its group). `ui.floating_panels` in `ui.inspect` lists the groups (panel ids, the -index of the tab shown, the top-left corner, `column`: the set it is stacked in, top to bottom in list order, and -`collapsed`) and `ui.toolbar_pos` the Tools panel's corner (null: docked); both are saved with the +index of the tab shown, the top-left corner, `column`: the set it is stacked in, top to bottom in list order, +`collapsed`, and `docked`: the dock column it stands in) and `ui.toolbar_pos` the Tools panel's corner (null: docked); both are saved with the preferences and in user workspaces (the built-in workspaces dock everything), and a corner saved on a bigger window is clamped into this one when drawn. Floating panels stay inside the app window (no separate OS windows). From a7279231cbf7d6c01e78a35a88f6c946e030ee67 Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Fri, 9 Oct 2026 18:48:40 +0000 Subject: [PATCH 61/97] UI: drop panels anywhere in a set or dock column, and on its tabs A group's upper half inserts the dropped groups right above it and its lower half right below it, in floating sets and dock columns alike, and a dock column's room under its groups puts them at its bottom; the edge bands were too thin to hit, and the one above a dock column's top group lay outside the column. The tabs a drop stacks panels with now light up over the panels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/ui-egui/src/floating.rs | 83 +++++++++++++++++++++++++++++----- crates/ui-egui/src/menus.rs | 2 +- docs/control-protocol.md | 5 +- 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index ed2cefdf8..531fe4b8d 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -9,9 +9,10 @@ //! × of a group puts its panels back in the dock too. The Tools panel floats the same way by its //! title bar ([`crate::toolbar`]) and docks again on the window's left edge. //! -//! Dropped on the top or bottom edge of another group (a line shows where), a group joins it in a -//! set: the groups stack top to bottom, each showing one of its panels, and move together by the -//! top group's title bar, whose × docks them all. A group dragged by its tab strip (a lone panel's +//! Dropped on the upper or lower half of another group (a line shows where), a group joins it in +//! a set right above or below it: the groups stack top to bottom, each showing one of its panels, +//! and move together by the top group's title bar, whose × docks them all; dropped on its tabs (lit +//! up), its panels join that group as tabs. A group dragged by its tab strip (a lone panel's //! tab, or the strip right of the tabs) leaves its set and floats on its own. A double-click on a //! tab collapses a group to its tabs, and another expands it. //! @@ -317,23 +318,35 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2 } } } - // Another group's top or bottom edge puts the moved groups in a set with it; its title bar and - // tabs stack them as tabs. + // Another group (floating or docked): its tabs (and a set's title bar) stack the moved groups + // with it as tabs; the upper half of the group (and just above a set) puts them in its set + // right above it, the lower half (and just below it) right below it. for (i, g) in app.ui.floating_panels.iter().enumerate() { let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue }; let Some(r) = ctx.data(|d| d.get_temp::(group_rect_id(first))) else { continue }; let line = |y: f32| Rect::from_x_y_ranges(r.x_range(), (y - 2.0)..=(y + 2.0)); - if Rect::from_x_y_ranges(r.x_range(), (r.bottom() - EDGE)..=(r.bottom() + EDGE)).contains(at) { - return Some((Drop::Below(first), line(r.bottom()))); - } let lead = leads(&app.ui, i); - if lead && Rect::from_x_y_ranges(r.x_range(), (r.top() - EDGE)..=r.top()).contains(at) { - return Some((Drop::Above(first), line(r.top()))); - } let head = Rect::from_min_size(r.min, vec2(r.width(), if lead { 1.0 + TITLE + STRIP } else { GAP + STRIP })); if head.contains(at) { return Some((Drop::Stack(first), head)); } + let above = if lead { EDGE } else { 0.0 }; + if Rect::from_x_y_ranges(r.x_range(), (r.top() - above)..=(r.bottom() + EDGE)).contains(at) { + let mid = (head.bottom() + r.bottom()) / 2.0; + return Some(if at.y < mid { (Drop::Above(first), line(r.top())) } else { (Drop::Below(first), line(r.bottom())) }); + } + } + // A dock column's room under its groups puts them at its bottom. + let columns = ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default(); + for ((_, set), col) in docked_sets(&app.ui).into_iter().zip(columns) { + if !col.contains(at) || set.iter().any(|i| moving.contains(i)) { + continue; + } + let Some(last) = set.last().and_then(|&i| app.ui.floating_panels.get(i)).and_then(|g| g.panels.first()).and_then(|p| panel(p)) else { + continue; + }; + let y = ctx.data(|d| d.get_temp::(group_rect_id(last.0))).map_or(col.bottom(), |r| r.bottom()); + return Some((Drop::Below(last.0), Rect::from_x_y_ranges(col.x_range(), (y - 2.0)..=(y + 2.0)))); } let dock = zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3)?; // Panels that aren't tabs of the tabbed group (or all of them, with it collapsed) go back to @@ -351,6 +364,13 @@ fn highlight(ctx: &egui::Context, zone: Rect) { painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); } +/// Light up the tabs a drop stacks the moved panels with, over the floating and docked groups. +fn tab_zone(ctx: &egui::Context, tabs: Rect) { + let t = Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("floating-drop-tabs"))); + painter.rect(tabs.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.25), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); +} + /// Show where a drop puts the moved groups in a set: a line over the floating groups. fn insertion_line(ctx: &egui::Context, line: Rect) { let t = Tokens::get(ctx); @@ -381,6 +401,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { } match target { Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line), + Some((Drop::Stack(_), tabs)) => tab_zone(ctx, tabs), Some((_, zone)) => highlight(ctx, zone), None => {} } @@ -757,7 +778,8 @@ fn draw_groups( if !g.collapsed { dock::panel_body(app, ui, g.active, width, tall); } - let bottom = ui.min_rect().bottom().max(strip.bottom()); + // Where the next widget would go: in a dock column, the ui's own bounds reach its bottom. + let bottom = ui.cursor().top().max(strip.bottom()); if let Some(&id) = g.ids.first() { let rect = Rect::from_min_max(pos2(strip.left(), top), pos2(strip.left() + width, bottom)); ui.ctx().data_mut(|d| d.insert_temp(group_rect_id(id), rect)); @@ -1496,6 +1518,43 @@ mod tests { assert_eq!(d, [(vec!["layers"], Some(2)), (vec!["color"], Some(1)), (vec!["stroke"], Some(1))]); } + #[test] + fn a_dock_column_takes_groups_anywhere_in_it_and_as_tabs() { + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "color", "column": true})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "swatches", "below": "color"})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "stroke", "below": "swatches"})).unwrap(); + h.settle(); + let order = |h: &Harness| h.app.ui.floating_panels.iter().map(|g| g.panels.join("+")).collect::>(); + // Dropped on the upper half of Swatches, Gradient goes in right above it. + h.app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 200})).unwrap(); + h.settle(); + let r = h.temp_rect(group_rect_id("swatches")); + let tabs_bottom = r.top() + GAP + STRIP; + let upper = pos2(r.center().x, tabs_bottom + (r.bottom() - tabs_bottom) / 4.0); + h.hold(h.title("gradient"), upper); + assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("gradient"), upper).map(|t| t.0), Some(Drop::Above("swatches"))); + h.release(upper); + assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke"]); + assert!(h.app.ui.floating_panels.iter().all(|g| g.docked == Some(1)), "all in the column"); + // Dropped on Stroke's tabs, Transparency joins Stroke's group as a tab. + h.app.run("window.panel.float", json!({"panel": "transparency", "x": 300, "y": 200})).unwrap(); + h.settle(); + let r = h.temp_rect(group_rect_id("stroke")); + let tabs = pos2(r.left() + 120.0, r.top() + GAP + STRIP / 2.0); + h.hold(h.title("transparency"), tabs); + assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("transparency"), tabs).map(|t| t.0), Some(Drop::Stack("stroke"))); + h.release(tabs); + assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke+transparency"]); + // Dropped low in the column, Align goes at its bottom. + h.app.run("window.panel.float", json!({"panel": "align", "x": 300, "y": 200})).unwrap(); + h.settle(); + let col = columns(&h)[0]; + h.drag(h.title("align"), pos2(col.center().x, col.bottom() - 6.0)); + assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke+transparency", "align"]); + assert!(h.app.ui.floating_panels.iter().all(|g| g.docked == Some(1))); + } + fn floating_pos(app: &VectorcraftApp, id: &str) -> [f32; 2] { group_of(&app.ui, id).map(|i| app.ui.floating_panels[i].pos).unwrap() } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 18ee497d0..d2b6af8b5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -260,7 +260,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel.float", "Float Panel", "", - "{panel: id or \"tools\", x?, y?, onto?: id, below?: id, above?: id, group?: bool, collapsed?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it as a tab with the floating group holding that panel instead; `below` / `above` puts its group in a set with that group, right below or above it, as dropping it on that group's bottom or top edge does (the set's groups stack top to bottom and move together); `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved (out of its set, given x, y); `collapsed` shows its group's tabs only, as a double-click on a tab does; a docked group floats its dock column. \"tools\" floats the Tools panel. Returns {panel, floating, group, set, pos, collapsed, column}", + "{panel: id or \"tools\", x?, y?, onto?: id, below?: id, above?: id, group?: bool, collapsed?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it as a tab with the floating group holding that panel instead; `below` / `above` puts its group in a set with that group, right below or above it, as dropping it on that group's lower or upper half does (the set's groups stack top to bottom and move together); `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved (out of its set, given x, y); `collapsed` shows its group's tabs only, as a double-click on a tab does; a docked group floats its dock column. \"tools\" floats the Tools panel. Returns {panel, floating, group, set, pos, collapsed, column}", ), ( "window.panel.dock", diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 71b5cfa85..36cd83646 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -260,8 +260,9 @@ Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own; dropped on another group's title bar or tabs it stacks with that group, and dropped on the dock (lit up while the -pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column. Dropped on another -group's bottom or top edge (a line shows where), a group joins it in a set: the groups stack top to bottom, each +pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column. Dropped on the upper or +lower half of another group, floating or docked (a line shows where), a group joins it in a set right above or below +it: the groups stack top to bottom, each showing one of its panels, and move together by the top group's title bar, whose × docks them all; a group dragged by its tab strip (a lone panel's tab or the strip right of the tabs) leaves the set. A double-click on a tab collapses a group to its tabs, and another expands it. Dropped on the left edge of the icon column or of a dock column, a set From b26612142f3ab7f6ccb8d6ba2ddd6513651ebc65 Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Fri, 9 Oct 2026 19:10:29 +0000 Subject: [PATCH 62/97] UI: dock columns scroll when their panels don't fit the window A dock column's groups scroll under its top strip, each open panel at least 160 points tall, instead of running off the bottom of the window. Drop zones in a column only use the part of a group in view. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/ui-egui/src/floating.rs | 67 ++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 531fe4b8d..d435a7a06 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -14,7 +14,8 @@ //! and move together by the top group's title bar, whose × docks them all; dropped on its tabs (lit //! up), its panels join that group as tabs. A group dragged by its tab strip (a lone panel's //! tab, or the strip right of the tabs) leaves its set and floats on its own. A double-click on a -//! tab collapses a group to its tabs, and another expands it. +//! tab collapses a group to its tabs, and another expands it. A dock column whose panels don't fit +//! the window scrolls. //! //! `window.panel.float` (`onto`, `below`, `above`, `collapsed`) / `window.panel.dock` do the same //! for agents. The groups, their sets and the Tools panel's position are kept with the preferences @@ -34,6 +35,8 @@ pub const TITLE: f32 = 14.0; pub const STRIP: f32 = 26.0; /// Height of the bar between two groups of a set. pub const GAP: f32 = 3.0; +/// The least height of an open panel in a dock column: more panels than fit scroll. +const MIN_TALL: f32 = 160.0; /// How far from a group's top or bottom edge a drop stacks it above or below the group. const EDGE: f32 = 7.0; /// Where `window.panel.float` floats a group given no position; each further group 24 points @@ -321,9 +324,17 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2 // Another group (floating or docked): its tabs (and a set's title bar) stack the moved groups // with it as tabs; the upper half of the group (and just above a set) puts them in its set // right above it, the lower half (and just below it) right below it. + let columns = ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default(); for (i, g) in app.ui.floating_panels.iter().enumerate() { let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue }; - let Some(r) = ctx.data(|d| d.get_temp::(group_rect_id(first))) else { continue }; + let Some(mut r) = ctx.data(|d| d.get_temp::(group_rect_id(first))) else { continue }; + // In a dock column that scrolls, only the part in view: a group scrolled away takes nothing. + if let Some(col) = g.docked.and_then(|d| columns.get(usize::try_from(d).ok()?.checked_sub(1)?)) { + r = r.intersect(*col); + if r.height() <= 0.0 || r.width() <= 0.0 { + continue; + } + } let line = |y: f32| Rect::from_x_y_ranges(r.x_range(), (y - 2.0)..=(y + 2.0)); let lead = leads(&app.ui, i); let head = Rect::from_min_size(r.min, vec2(r.width(), if lead { 1.0 + TITLE + STRIP } else { GAP + STRIP })); @@ -337,7 +348,6 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2 } } // A dock column's room under its groups puts them at its bottom. - let columns = ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default(); for ((_, set), col) in docked_sets(&app.ui).into_iter().zip(columns) { if !col.contains(at) || set.iter().any(|i| moving.contains(i)) { continue; @@ -345,7 +355,7 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2 let Some(last) = set.last().and_then(|&i| app.ui.floating_panels.get(i)).and_then(|g| g.panels.first()).and_then(|p| panel(p)) else { continue; }; - let y = ctx.data(|d| d.get_temp::(group_rect_id(last.0))).map_or(col.bottom(), |r| r.bottom()); + let y = ctx.data(|d| d.get_temp::(group_rect_id(last.0))).map_or(col.bottom(), |r| r.bottom()).min(col.bottom() - 2.0); return Some((Drop::Below(last.0), Rect::from_x_y_ranges(col.x_range(), (y - 2.0)..=(y + 2.0)))); } let dock = zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3)?; @@ -633,8 +643,15 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec } let n = groups.len() as f32; let open = groups.iter().filter(|g| !g.collapsed).count().max(1) as f32; - let tall = ((ui.available_height() - n * STRIP - (n - 1.0) * GAP - 24.0 * open) / open).max(80.0); - draw_groups(app, ui, &groups, width, tall, bar.top(), None, &mut action); + // The open panels share the column's height, each at least `MIN_TALL`: more than fit + // scroll, with the column. + let tall = ((ui.available_height() - n * STRIP - (n - 1.0) * GAP - 24.0 * open) / open).max(MIN_TALL); + egui::ScrollArea::vertical().id_salt(("dock-column-scroll", first)).auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing = Vec2::ZERO; + let width = ui.available_width(); + let top = ui.cursor().top(); + draw_groups(app, ui, &groups, width, tall, top, None, &mut action); + }); }); rects.push(panel.response.rect); } @@ -1537,7 +1554,11 @@ mod tests { h.release(upper); assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke"]); assert!(h.app.ui.floating_panels.iter().all(|g| g.docked == Some(1)), "all in the column"); - // Dropped on Stroke's tabs, Transparency joins Stroke's group as a tab. + // Dropped on Stroke's tabs, Transparency joins Stroke's group as a tab (Color and Gradient + // collapsed, so Stroke is in view in the column, which scrolls). + for g in h.app.ui.floating_panels.iter_mut().take(2) { + g.collapsed = true; + } h.app.run("window.panel.float", json!({"panel": "transparency", "x": 300, "y": 200})).unwrap(); h.settle(); let r = h.temp_rect(group_rect_id("stroke")); @@ -1546,7 +1567,11 @@ mod tests { assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("transparency"), tabs).map(|t| t.0), Some(Drop::Stack("stroke"))); h.release(tabs); assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke+transparency"]); - // Dropped low in the column, Align goes at its bottom. + // Dropped in the room under the groups (three collapsed to make some), Align goes at the + // column's bottom. + for g in h.app.ui.floating_panels.iter_mut().take(3) { + g.collapsed = true; + } h.app.run("window.panel.float", json!({"panel": "align", "x": 300, "y": 200})).unwrap(); h.settle(); let col = columns(&h)[0]; @@ -1555,6 +1580,32 @@ mod tests { assert!(h.app.ui.floating_panels.iter().all(|g| g.docked == Some(1))); } + #[test] + fn a_dock_column_too_tall_for_the_window_scrolls() { + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "layers", "column": true})).unwrap(); + let mut above = "layers"; + for p in ["properties", "color", "swatches", "stroke", "gradient", "transparency", "align"] { + h.app.run("window.panel.float", json!({"panel": p, "below": above})).unwrap(); + above = p; + } + h.settle(); + let col = columns(&h)[0]; + assert!(col.bottom() <= SCREEN.y + 1.0, "the column stays in the window: {col:?}"); + let last = h.temp_rect(group_rect_id("align")); + assert!(last.top() > col.bottom(), "Align starts below the window: {last:?} {col:?}"); + // The wheel over the column (Color's tabs, outside any panel's own scrolling) scrolls it, + // bringing Align up. + let color = h.temp_rect(group_rect_id("color")); + let over = pos2(color.left() + 150.0, color.top() + GAP + STRIP / 2.0); + for _ in 0..4 { + h.frame(crate::toolbar::tests::wheel(over, 300.0)); + } + h.settle(); + let moved = h.temp_rect(group_rect_id("align")); + assert!(moved.top() < last.top() - 100.0, "scrolled up: {moved:?} from {last:?}"); + } + fn floating_pos(app: &VectorcraftApp, id: &str) -> [f32; 2] { group_of(&app.ui, id).map(|i| app.ui.floating_panels[i].pos).unwrap() } From aa17dd7d2cefaa143f58a1e04a223fc884fca6c5 Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Fri, 9 Oct 2026 20:16:23 +0000 Subject: [PATCH 63/97] i18n: Japanese for the panel-set command errors Japanese now translates every status and error message; add the three messages window.panel.float and window.panel.dock gained. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/ui-egui/src/i18n/ja.tsv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 547525c38..bc7c5ac25 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3797,6 +3797,9 @@ theme Appearance 外観 @msg give the path `id` and the width point `index` パスの `id` と幅ポイントの `index` を指定してください @msg no such width point 指定された幅ポイントが見つかりません @msg onto must name a panel floating in another group onto には、別のグループでフローティングしているパネルを指定してください +@msg below and above must name a panel floating in another group below と above には別のグループでフローティングしているパネルを指定してください +@msg give only one of onto, below and above onto、below、above のうち 1 つだけを指定してください +@msg column must be true or a number from 1 column には true または 1 以上の数値を指定してください @msg unknown panel `{raw}` `{raw}` は不明なパネルです @msg x and y must both be numbers x と y はどちらも数値で指定してください @msg Downloaded {what} {what} をダウンロードしました From db39545a8561b06355776950407e5bc84e9a7c81 Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Sat, 10 Oct 2026 17:50:04 +0000 Subject: [PATCH 64/97] i18n: German for the panel-set strings German is now a complete language; add the Drag to move out of the set tooltip and the three errors window.panel.float and window.panel.dock gained. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/ui-egui/src/i18n/de.tsv | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 79021e6c7..fd6bff038 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3213,6 +3213,7 @@ axis Vertical Vertikal axis Both Beide theme Appearance Erscheinungsbild Drag to move Zum Verschieben ziehen + Drag to move out of the set Ziehen, um aus dem Satz zu lösen Drag to float as a toolbar Ziehen, um als eigene Werkzeugleiste abzulösen Put back in the toolbar Zurück in die Werkzeugleiste setzen Float Panel Bedienfeld ablösen @@ -3769,6 +3770,9 @@ theme Appearance Erscheinungsbild @msg tool (a tool id) is required tool (eine Werkzeug-ID) ist erforderlich @msg {tool} isn't in a toolbar group of several tools {tool} gehört zu keiner Werkzeuggruppe mit mehreren Werkzeugen @msg onto must name a panel floating in another group onto muss ein Bedienfeld nennen, das in einer anderen Gruppe schwebt +@msg below and above must name a panel floating in another group below und above müssen ein Bedienfeld nennen, das in einer anderen Gruppe schwebt +@msg give only one of onto, below and above nur eines von onto, below und above angeben +@msg column must be true or a number from 1 column muss true oder eine Zahl ab 1 sein @msg x and y must both be numbers x und y müssen beide Zahlen sein @msg "{0}" is not a colour: write it as #rrggbb "{0}" ist keine Farbe: Schreiben Sie sie als #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead dies ist kein Logo aus flächigen Farben ({_1} % seiner einfarbigen Bereiche entsprechen wenigen flächigen Farben), daher würde der Modus „Flaches Logo“ seine Verläufe in Flecken zerlegen: Verwenden Sie stattdessen den Modus „Farbe“ From 53f8516cc628ea56f16d4ecca43446680df190f3 Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Sat, 10 Oct 2026 18:29:58 +0000 Subject: [PATCH 65/97] Docs: panel sets and dock columns in the parity docs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dbab50eec..d428e05fd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -375,7 +375,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Hint bar, contextual task bar, 19 dock panels with ≡ menus. - The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it). - Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it. - - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels. + - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into the default tabbed group it doesn't belong to, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, German, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ffe3bb2bf..036208f5e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -143,6 +143,7 @@ Layers panel. |---|---|---| | ~40 panels with ≡ menus; dock, icon column, collapse, tabs | done | 51 panel modules incl. tests | | Panels float inside the window, stack, dock back | done | #495 | +| Floating groups stack into sets; sets dock as extra dock columns (a multi-column dock); groups drop anywhere in a set or column, or onto its tabs; a column taller than the window scrolls | done | `window.panel.float {below, above}`, `window.panel.dock {column}` | | Panels outside the app window (another monitor) | missing | single OS window | | Properties panel per context (every selection type's Quick Actions) | partial | | | Contextual task bar | done | | From 33d6d38ec8332f2a19ca6202041b0cad77979e86 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 05:19:43 +0700 Subject: [PATCH 66/97] ROADMAP: panel sets and dock columns in the progress log (#1020) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9c018a015..7397cbdbe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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 | From 02e93902b30e673913259150db755326af0850a4 Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Fri, 9 Oct 2026 19:22:52 +0000 Subject: [PATCH 67/97] Guides: ruler guides as layer objects A ruler guide goes on the current layer (guide.add {layer}), shows, hides, locks and is deleted with its layer, is listed in the Layers panel as a row that selects it, and moves to another layer (guide.setLayer). Guides on a hidden layer neither show nor snap; those on a locked layer can't be picked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/doc/src/lib.rs | 20 +++++++++- crates/doc/src/selection.rs | 18 +++++++++ crates/engine/src/cmd/docmenu.rs | 47 ++++++++++++++++++++-- crates/engine/src/cmd/layer.rs | 3 ++ crates/engine/src/tests_menucmds.rs | 45 +++++++++++++++++++-- crates/tools/src/guides.rs | 8 +++- crates/tools/src/rulerguide.rs | 5 ++- crates/tools/src/slice.rs | 2 +- crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 3 ++ crates/ui-egui/src/i18n/es.tsv | 3 ++ crates/ui-egui/src/i18n/fr.tsv | 3 ++ crates/ui-egui/src/i18n/it.tsv | 3 ++ crates/ui-egui/src/i18n/ja.tsv | 3 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 3 ++ crates/ui-egui/src/i18n/uk.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 ++ crates/ui-egui/src/panels/layers.rs | 61 ++++++++++++++++++++++++++--- docs/control-protocol.md | 5 +++ 22 files changed, 230 insertions(+), 18 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3c555def9..9ff6526cc 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -402,12 +402,17 @@ pub struct Guide { /// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas. #[serde(default, skip_serializing_if = "Option::is_none")] pub artboard: Option, + /// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed + /// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides + /// from before guides had layers, and from files that don't say), always there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer: Option, } impl Guide { /// A canvas guide. pub fn new(vertical: bool, pos: f64) -> Self { - Self { vertical, pos, artboard: None } + Self { vertical, pos, artboard: None, layer: None } } /// Moved by `d` (a vertical guide across, a horizontal one down). @@ -990,6 +995,19 @@ impl Document { let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect; Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }) } + /// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is + /// visible? A guide whose layer is gone shows as one on no layer. + pub fn guide_shown(&self, g: &Guide) -> bool { + g.layer.is_none_or(|l| self.node(l).is_none() || self.is_visible(l)) + } + /// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked? + pub fn guide_editable(&self, g: &Guide) -> bool { + g.layer.is_none_or(|l| self.node(l).is_none() || self.is_editable(l)) + } + /// The ruler guides on layer `layer` (not its sublayers'), by index. + pub fn guides_on(&self, layer: NodeId) -> impl Iterator + '_ { + self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer)) + } /// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)? pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool { let along = if g.vertical { p.y } else { p.x }; diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index e1e98de1e..8f368b280 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -206,6 +206,24 @@ mod tests { assert_eq!(s.guides, vec![0]); } + #[test] + fn guides_show_and_lock_with_their_layer() { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) }; + assert!(d.guide_shown(&g) && d.guide_editable(&g)); + d.node_mut(layer).unwrap().locked = true; + assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable"); + d.node_mut(layer).unwrap().visible = false; + assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden"); + // On no layer, or a layer that's gone: always there. + assert!(d.guide_shown(&crate::Guide::new(false, 5.0))); + let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) }; + assert!(d.guide_shown(&gone) && d.guide_editable(&gone)); + d.guides.push(g); + assert_eq!(d.guides_on(layer).count(), 1); + } + #[test] fn selection_changes_clear_the_target() { let mut s = Selection::default(); diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 973d501d5..5d03a5c32 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -160,7 +160,7 @@ pub fn specs() -> Vec { "Add Guide", [], None, - "{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas)} → {index}", + "{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas), layer?: id (the layer it is on, shown, hidden, locked and deleted with it; default the current layer)} → {index}", has_doc, guide_add ), @@ -169,7 +169,7 @@ pub fn specs() -> Vec { "Guides", [], None, - "{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's)}…] the ruler guides", + "{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), shown, editable (its layer shown and unlocked)}…] the ruler guides", has_doc, guide_list ), @@ -200,6 +200,15 @@ pub fn specs() -> Vec { guides_unlocked, guide_move ), + cmd!( + "guide.setLayer", + "Move Guide to Layer", + [], + None, + "{index?, layer: id} put ruler guide `index`, else the selected guides, on layer `layer` (a layer or sublayer), as for art moved to another layer → {count}", + guides_unlocked, + guide_set_layer + ), cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all), cmd!( "file.documentColorMode", @@ -507,8 +516,13 @@ fn guide_add(s: &mut Session, p: &Value) -> Result { } None => None, }; + let layer = match id_param(p, "layer") { + Some(l) if s.doc()?.doc.node(l).is_some_and(Node::is_layer) => Some(l), + Some(_) => return Err(bad("guide.add", "layer must name a layer or sublayer")), + None => s.doc()?.current_layer(), + }; let i = s.edit("New Guide", |d, _| { - d.guides.push(Guide { artboard, ..Guide::new(vertical, pos) }); + d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) }); Ok(d.guides.len() - 1) })?; Ok(json!({ "index": i })) @@ -521,6 +535,11 @@ fn guide_list(s: &mut Session, _: &Value) -> Result { if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) { r["artboard"] = json!(ab); } + if let Some(l) = g.layer { + r["layer"] = json!(l.0); + } + r["shown"] = json!(st.doc.guide_shown(g)); + r["editable"] = json!(st.doc.guide_editable(g)); r }; Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect())) @@ -568,6 +587,28 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { Ok(json!({ "count": gone.len() })) } +/// Put ruler guide `index`, else the selected guides, on layer `layer`, in one undo step. +fn guide_set_layer(s: &mut Session, p: &Value) -> Result { + let layer = id_param(p, "layer").filter(|l| s.doc().is_ok_and(|st| st.doc.node(*l).is_some_and(Node::is_layer))); + let layer = layer.ok_or_else(|| bad("guide.setLayer", "layer must name a layer or sublayer"))?; + let moving: Vec = match p.get("index") { + Some(v) => vec![guide_index(s, Some(v), "guide.setLayer")?], + None => s.doc()?.selection.guides.clone(), + }; + if moving.is_empty() { + return Err(bad("guide.setLayer", "no guide selected (or give an index)")); + } + s.edit("Move Guide to Layer", |d, _| { + for i in &moving { + if let Some(g) = d.guides.get_mut(*i) { + g.layer = Some(layer); + } + } + Ok(()) + })?; + Ok(json!({ "count": moving.len() })) +} + /// Put ruler guide `index` at `pos`, else move (or copy) the selected guides, in one undo step. pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("index") { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index a80d3e9d2..6905d2d2a 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -462,6 +462,9 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result { for id in &ids { d.remove(*id)?; } + // The guides on the deleted layers go with them. + let orphans: Vec = d.guides.iter().enumerate().filter(|(_, g)| g.layer.is_some_and(|l| d.node(l).is_none())).map(|(i, _)| i).collect(); + d.retain_guides(sel, |i, _| !orphans.contains(&i)); sel.prune(d); Ok(()) })?; diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index d852915e2..721b4717d 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -785,7 +785,11 @@ fn ruler_guides_select_move_and_delete() { assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([0])); assert!(selected(&s).is_empty()); assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1])); - assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1], json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true})); + let layer = s.doc().unwrap().current_layer().unwrap().0; + assert_eq!( + s.execute("guide.list", &json!({})).unwrap()[1], + json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true, "layer": layer, "shown": true, "editable": true}) + ); assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err()); // A move takes each selected guide along its own axis, in one undo step. let n = undo_len(&s); @@ -866,11 +870,12 @@ fn artboard_guides_go_with_their_artboard() { s.execute("guide.add", &json!({"vertical": true, "pos": 1000, "artboard": 1})).unwrap(); s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap(); let list = |s: &mut Session| s.execute("guide.list", &json!({})).unwrap(); + let layer = s.doc().unwrap().current_layer().unwrap().0; assert_eq!( list(&mut s), json!([ - {"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1}, - {"index": 1, "vertical": false, "pos": 100.0, "selected": false}, + {"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true}, + {"index": 1, "vertical": false, "pos": 100.0, "selected": false, "layer": layer, "shown": true, "editable": true}, ]) ); let spans = |s: &Session| { @@ -951,12 +956,44 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() { s.set_tool_option("active", &json!(1)); s.ruler_guide(false, &ev(PointerKind::Drag, 1000.0, 50.0), true, v).unwrap(); s.ruler_guide(false, &ev(PointerKind::Up, 1000.0, 50.0), true, v).unwrap(); + let layer = s.doc().unwrap().current_layer().unwrap().0; assert_eq!( s.execute("guide.list", &json!({})).unwrap()[1], - json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1}) + json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true}) ); } +/// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it, +/// and moved to another layer. +#[test] +fn ruler_guides_live_on_layers() { + let mut s = session(); + let first = s.doc().unwrap().current_layer().unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[0].layer, Some(first), "on the current layer"); + let second = id_of(&s.execute("layer.new", &json!({"name": "Guides"})).unwrap()); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(second), "the new layer is current"); + assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 1, "layer": 99999})).is_err()); + // Hidden or locked with its layer. + s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1]["shown"], false); + s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap(); + let row = s.execute("guide.list", &json!({})).unwrap()[1].clone(); + assert_eq!((row["shown"].clone(), row["editable"].clone()), (json!(true), json!(false))); + s.execute("layer.setProps", &json!({"ids": [second.0], "locked": false})).unwrap(); + // Moved to the first layer, then back. + s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first)); + s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap(); + assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err()); + // Deleting a layer deletes its guides; undo brings them back. + s.execute("layer.delete", &json!({"ids": [second.0]})).unwrap(); + assert_eq!(guides(&s), [(true, 200.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 2); +} + /// #451: art moved with the Selection tool lands flush on the artboard's edges and centre (Smart /// Guides). #[test] diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 519c47aef..065fa41bd 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -372,7 +372,13 @@ impl Targets { /// tools): they pull a point into line where they run. fn with_rulers(mut self, cx: &ToolContext) -> Self { if cx.guides { - self.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() + .filter(|g| cx.doc.guide_shown(g)) + .map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }) + .collect(); } self } diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index dccdeed13..d798e260f 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -5,7 +5,8 @@ //! selected guides (Alt copies them). A dragged guide snaps ([`GuideSnap`]): with Shift to the //! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards' //! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its -//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides) can't be picked. +//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden +//! or locked layer, can't be picked. use serde_json::json; use vectorcraft_geom::Point; @@ -24,7 +25,7 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option { .guides .iter() .enumerate() - .filter(|(_, g)| cx.doc.guide_passes(g, p, tol)) + .filter(|(_, g)| cx.doc.guide_editable(g) && cx.doc.guide_passes(g, p, tol)) .map(|(i, g)| (i, off(g.pos, g.vertical))) .filter(|(_, d)| *d <= tol) .min_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0))) diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs index 41d86438e..f1f31643f 100644 --- a/crates/tools/src/slice.rs +++ b/crates/tools/src/slice.rs @@ -43,7 +43,7 @@ fn snap(cx: &ToolContext, p: Point) -> (Point, Vec) { cx.doc .guides .iter() - .filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_passes(g, p, tol)) + .filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_shown(g) && cx.doc.guide_passes(g, p, tol)) .min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs())) }; if let Some(g) = nearest(true, p.x) { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 16fc26a5c..4e30d209f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -378,7 +378,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Selected guides in the selection colour. let picked = LineLook { color: t.selection, ..look }; let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides); - for (i, g) in doc.guides.iter().enumerate() { + // Guides on a hidden layer hide with it. + for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) { let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) }); // An artboard guide runs across its artboard, a canvas guide across the window. let (a, b) = match doc.guide_span(g) { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e3a0dda6a..39ce8fc0d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -732,3 +732,5 @@ lighting Light Světlo Spatter… Rozstřik… Sprayed Strokes… Stříkané tahy… Sumi-e… Sumi-e… + Vertical Guide Svislé vodítko + Horizontal Guide Vodorovné vodítko diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fd6bff038..cbfcfe728 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -362,6 +362,9 @@ Position Panel Above Text Bedienfeld über dem Text positionieren Clear Appearance Aussehen löschen Clear Guides Hilfslinien löschen + Vertical Guide Vertikale Hilfslinie + Horizontal Guide Horizontale Hilfslinie + Move Guide to Layer Hilfslinie auf Ebene verschieben Clear Overrides Abweichungen löschen Clear Recent Files Liste der zuletzt geöffneten Dateien leeren Click Klicken diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 22b4dc9b7..58260ddd7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4097,3 +4097,6 @@ lighting Light Luz Stroke Pressure Presión de pincelada Right Diagonal Diagonal derecha Left Diagonal Diagonal izquierda + Vertical Guide Guía vertical + Horizontal Guide Guía horizontal + Move Guide to Layer Mover guía a capa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0c4c1467d..ae1b2a4df 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4107,3 +4107,6 @@ lighting Light Lumière Stroke Pressure Pression des traits Right Diagonal Diagonale droite Left Diagonal Diagonale gauche + Vertical Guide Repère vertical + Horizontal Guide Repère horizontal + Move Guide to Layer Déplacer le repère vers le calque diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 070b9318f..8d8ee1a5b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4099,3 +4099,6 @@ lighting Light Luce Stroke Pressure Pressione pennellata Right Diagonal Diagonale destra Left Diagonal Diagonale sinistra + Vertical Guide Guida verticale + Horizontal Guide Guida orizzontale + Move Guide to Layer Sposta guida nel livello diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index bc7c5ac25..07e37509b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4092,3 +4092,6 @@ lighting Light ライト Stroke Pressure ストロークの筆圧 Right Diagonal 右斜め Left Diagonal 左斜め + Vertical Guide 垂直ガイド + Horizontal Guide 水平ガイド + Move Guide to Layer ガイドをレイヤーへ移動 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b7df5bd07..40d715df7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2790,3 +2790,5 @@ lighting Light Luz Spatter… Respingo… Sprayed Strokes… Traços Borrifados… Sumi-e… Sumi-e… + Vertical Guide Guia vertical + Horizontal Guide Guia horizontal diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b950958f4..4489f231a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4095,3 +4095,6 @@ lighting Light Свет Stroke Pressure Нажим мазка Right Diagonal Диагональ вправо Left Diagonal Диагональ влево + Vertical Guide Вертикальная направляющая + Horizontal Guide Горизонтальная направляющая + Move Guide to Layer Переместить направляющую на слой diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8171d8902..e9a7c5472 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4078,3 +4078,6 @@ lighting Light Світло Stroke Pressure Натиск мазка Right Diagonal Діагональ праворуч Left Diagonal Діагональ ліворуч + Vertical Guide Вертикальна напрямна + Horizontal Guide Горизонтальна напрямна + Move Guide to Layer Перемістити напрямну на шар diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05d705b5a..c4ed3cd12 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3724,3 +3724,6 @@ lighting Light 光源 Stroke Pressure 描边压力 Right Diagonal 右对角线 Left Diagonal 左对角线 + Vertical Guide 垂直参考线 + Horizontal Guide 水平参考线 + Move Guide to Layer 将参考线移至图层 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e5e999800..2827e6141 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3505,3 +3505,6 @@ lighting Light 光源 Stroke Pressure 筆觸壓力 Right Diagonal 右對角線 Left Diagonal 左對角線 + Vertical Guide 垂直參考線 + Horizontal Guide 水平參考線 + Move Guide to Layer 將參考線移至圖層 diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 87c6a0fc9..c05500bf2 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -220,6 +220,8 @@ struct Row { colour: Color32, /// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer. dim: bool, + /// A ruler guide on the layer `node` (its index in the document's guides): the guide's row. + guide: Option, } /// What the rows were listed from: listed again when any of it changes. @@ -239,7 +241,7 @@ fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str) let mask_layer = doc.mask_edit.map(|m| m.layer); let mut out = vec![]; for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { - push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query), &mut out); + push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, doc), &mut out); } out } @@ -251,10 +253,10 @@ fn push_rows( depth: usize, clip_path: bool, (colour, shown): (Color32, bool), - cx: (&OpenRows, &PanelOptions, &str), + cx: (&OpenRows, &PanelOptions, &str, &Document), out: &mut Vec, ) { - let (open_rows, opts, query) = cx; + let (open_rows, opts, query, doc) = cx; if opts.layers_only && !n.is_layer() { return; } @@ -266,11 +268,18 @@ fn push_rows( _ => colour, }; let shown = shown && n.visible; - let opens = opens(n, opts); let searching = !query.is_empty(); + // A layer's ruler guides are rows in it, above its art (not while searching). + let guides: Vec = if n.is_layer() && !opts.layers_only && !searching { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] }; + let opens = opens(n, opts) || !guides.is_empty(); let open = opens && (searching || open_rows.contains(n.id)); let at = out.len(); - out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template() }); + out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template(), guide: None }); + if open { + for i in guides.into_iter().rev() { + out.push(Row { node: n.clone(), depth: depth + 1, clip_path: false, opens: false, open: false, colour, dim: !shown, guide: Some(i) }); + } + } if open && let Some(children) = n.children() { for (i, c) in children.iter().enumerate().rev() { push_rows(c, depth + 1, i == 0 && n.clips(), (colour, shown), cx, out); @@ -302,6 +311,8 @@ fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions, struct View<'a> { doc: &'a Document, sel: HashSet, + /// The selected ruler guides. + guides: Vec, target: Option, current: Option, rows: Vec, @@ -362,6 +373,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let view = View { doc: &doc, sel: st.selection.objects.iter().copied().collect(), + guides: st.selection.guides.clone(), target: st.selection.target, current: st.current_layer(), rows, @@ -542,8 +554,47 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { } } +/// The row of ruler guide `i` on the layer of `item`: a guide mark and its name, the selection +/// square when it is selected. A click selects the guide; Shift or Cmd adds it to (or takes it +/// from) the selected guides. +fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { + let (t, h) = (&view.t, view.h); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click()); + let Some(g) = view.doc.guides.get(i) else { return }; + let selected = view.guides.contains(&i); + if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); + } + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); + let mut x = r.left() + 2.0 * COLUMN + 2.0; + colour_bar(ui, &item.node, r, x, item.colour); + x += 6.0 + item.depth as f32 * INDENT + 16.0; + // The guide mark: a short line across or down, in the guides' colour. + let c = egui::pos2(x + 8.0, r.center().y); + let half = if g.vertical { vec2(0.0, 6.0) } else { vec2(6.0, 0.0) }; + ui.painter().line_segment([c - half, c + half], Stroke::new(1.5, t.accent)); + x += 22.0; + let name = format!("<{}>", if g.vertical { tl!("Vertical Guide") } else { tl!("Horizontal Guide") }); + let color = if item.dim { t.text_dim } else { t.text }; + ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), color); + let col_x = r.right() - 43.5; + ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); + if selected { + let q = egui::Rect::from_center_size(egui::pos2(r.right() - SQUARE_X, r.center().y), vec2(7.0, 7.0)); + ui.painter().rect_filled(q, 0.0, item.colour); + ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside); + } + if resp.clicked() { + let m = ui.input(|i| i.modifiers); + out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command}))); + } +} + /// The widgets of row `item`. fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { + if let Some(i) = item.guide { + return guide_row(ui, view, item, i, out); + } let n = &*item.node; let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour); let doc = view.doc; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 36cd83646..2f1be11d6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -256,6 +256,11 @@ Constrain acts as Shift held (proportional scaling, moves and rotations by 45°) where it is greyed. The modifier keys still work while dragging (Cmd on a corner distorts it freely, Cmd+Alt+Shift in perspective, Cmd on a side shears). +Ruler guides are layer objects: `guide.add` puts a guide on the current layer (or `layer`), where it shows, hides, +locks and is deleted with its layer and is listed in the Layers panel (a click on its row selects it); +`guide.setLayer {index?, layer}` moves guides to another layer, and `guide.list` gives each +guide's `layer`, `shown` and `editable`. + Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own; From 2972e2e6bf6f8d7162166a05c5cb979c5a76b471 Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Fri, 9 Oct 2026 19:28:26 +0000 Subject: [PATCH 68/97] Guides: drag guide rows to another layer; put older guides on a layer Guide rows in the Layers panel drag (the selected guides when the row is one of them) onto any row of another layer, which lights up, and go to that layer in one undo step (guide.setLayer takes indexes). Guides on no layer, from files saved before guides had layers, go on the top visible, unlocked layer when a document opens or is reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/doc/src/lib.rs | 18 +++++++++++++ crates/doc/src/selection.rs | 8 ++++++ crates/engine/src/cmd/docmenu.rs | 9 ++++--- crates/engine/src/lib.rs | 3 +++ crates/engine/src/tests_menucmds.rs | 23 ++++++++++++++++- crates/ui-egui/src/panels/layers.rs | 39 +++++++++++++++++++++++++++-- docs/control-protocol.md | 7 +++--- 7 files changed, 97 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9ff6526cc..3f4e6df9d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1004,6 +1004,24 @@ impl Document { pub fn guide_editable(&self, g: &Guide) -> bool { g.layer.is_none_or(|l| self.node(l).is_none() || self.is_editable(l)) } + /// Put the ruler guides on no layer (from files saved before guides had layers), or on one that + /// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many. + pub fn adopt_guides(&mut self) -> usize { + let Some(home) = self.default_layer() else { return 0 }; + let lost: Vec = self + .guides + .iter() + .enumerate() + .filter(|(_, g)| g.layer.is_none_or(|l| !self.node(l).is_some_and(Node::is_layer))) + .map(|(i, _)| i) + .collect(); + for i in &lost { + if let Some(g) = self.guides.get_mut(*i) { + g.layer = Some(home); + } + } + lost.len() + } /// The ruler guides on layer `layer` (not its sublayers'), by index. pub fn guides_on(&self, layer: NodeId) -> impl Iterator + '_ { self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer)) diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 8f368b280..5c09d95e6 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -222,6 +222,14 @@ mod tests { assert!(d.guide_shown(&gone) && d.guide_editable(&gone)); d.guides.push(g); assert_eq!(d.guides_on(layer).count(), 1); + // Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer. + d.node_mut(layer).unwrap().visible = true; + d.node_mut(layer).unwrap().locked = false; + d.guides.push(crate::Guide::new(false, 5.0)); + d.guides.push(gone); + assert_eq!(d.adopt_guides(), 2); + assert!(d.guides.iter().all(|g| g.layer == Some(layer))); + assert_eq!(d.adopt_guides(), 0, "once"); } #[test] diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 5d03a5c32..4282905f4 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -205,7 +205,7 @@ pub fn specs() -> Vec { "Move Guide to Layer", [], None, - "{index?, layer: id} put ruler guide `index`, else the selected guides, on layer `layer` (a layer or sublayer), as for art moved to another layer → {count}", + "{index?, indexes?: [index…], layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does → {count}", guides_unlocked, guide_set_layer ), @@ -591,9 +591,10 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { fn guide_set_layer(s: &mut Session, p: &Value) -> Result { let layer = id_param(p, "layer").filter(|l| s.doc().is_ok_and(|st| st.doc.node(*l).is_some_and(Node::is_layer))); let layer = layer.ok_or_else(|| bad("guide.setLayer", "layer must name a layer or sublayer"))?; - let moving: Vec = match p.get("index") { - Some(v) => vec![guide_index(s, Some(v), "guide.setLayer")?], - None => s.doc()?.selection.guides.clone(), + let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { + (Some(v), _) => vec![guide_index(s, Some(v), "guide.setLayer")?], + (None, Some(list)) => list.iter().take(10_000).map(|v| guide_index(s, Some(v), "guide.setLayer")).collect::>>()?, + (None, None) => s.doc()?.selection.guides.clone(), }; if moving.is_empty() { return Err(bad("guide.setLayer", "no guide selected (or give an index)")); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 95f0ad8f9..7de3ed337 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1041,6 +1041,8 @@ impl Session { /// Add a document and make it active. pub fn add_document(&mut self, mut doc: Document, path: Option) -> usize { Self::refresh_text_bounds(&mut doc); + // Guides from files saved before guides had layers go on a layer. + doc.adopt_guides(); self.reset_tool_for_doc_switch(); let mut st = DocState::new(doc, path); st.history.limit = self.prefs.history_states as usize; @@ -1056,6 +1058,7 @@ impl Session { return false; } Self::refresh_text_bounds(&mut doc); + doc.adopt_guides(); if self.active == Some(index) { // Pending tool work (typing, a drag) belongs to the content being thrown away. self.reset_tool_for_doc_switch(); diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 721b4717d..1ac208876 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -914,7 +914,10 @@ fn artboard_guides_go_with_their_artboard() { let n = undo_len(&s); s.execute("artboard.delete", &json!({"index": 0})).unwrap(); assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1])); - assert_eq!(list(&mut s)[0], json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true})); + assert_eq!( + list(&mut s)[0], + json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true, "layer": layer, "shown": true, "editable": true}) + ); assert_eq!(undo_len(&s), n + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!((guides(&s).len(), selected_guides(&s)), (4, vec![1, 2])); @@ -963,6 +966,19 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() { ); } +/// Guides saved before guides had layers are put on a layer when the document opens. +#[test] +fn guides_from_older_files_are_put_on_a_layer() { + let mut s = Session::new(); + let mut d = vectorcraft_doc::Document::new(400.0, 300.0); + d.guides.push(vectorcraft_doc::Guide::new(true, 50.0)); + d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + s.add_document(d, None); + let st = s.doc().unwrap(); + let top = st.doc.default_layer(); + assert!(top.is_some() && st.doc.guides.iter().all(|g| g.layer == top), "{:?}", st.doc.guides); +} + /// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it, /// and moved to another layer. #[test] @@ -987,6 +1003,11 @@ fn ruler_guides_live_on_layers() { assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first)); s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap(); assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err()); + // Several at once, as dragging their rows onto a layer's row does. + s.execute("guide.setLayer", &json!({"indexes": [0, 1], "layer": first.0})).unwrap(); + assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.layer == Some(first))); + s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap(); + assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err()); // Deleting a layer deletes its guides; undo brings them back. s.execute("layer.delete", &json!({"ids": [second.0]})).unwrap(); assert_eq!(guides(&s), [(true, 200.0)]); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index c05500bf2..9ac35b880 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -98,6 +98,9 @@ enum LayersDrag { Rows(Vec), /// The selected-art square: the selected objects go to the row it is dropped on. Art, + /// Ruler guide rows (indexes into the document's guides): they go to the layer of the row + /// they are dropped on. + Guides(Vec), } /// A drag down the eye or lock column: every row it passes takes `value`. @@ -549,6 +552,7 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { NodeKind::Group { .. } => match drag { LayersDrag::Rows(ids) => !ids.iter().any(|i| doc.node(*i).is_some_and(Node::is_layer)), LayersDrag::Art => true, + LayersDrag::Guides(_) => false, }, _ => false, } @@ -559,7 +563,7 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { /// from) the selected guides. fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { let (t, h) = (&view.t, view.h); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); let Some(g) = view.doc.guides.get(i) else { return }; let selected = view.guides.contains(&i); if resp.hovered() { @@ -588,6 +592,29 @@ fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { let m = ui.input(|i| i.modifiers); out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command}))); } + // Dragged: the selected guides when this is one of them, else this one, to another layer's row. + if resp.drag_started() { + let guides = if selected { view.guides.clone() } else { vec![i] }; + egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides)); + } + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + // Guides dropped here go to this guide's layer. + if let Some(drag) = resp.dnd_hover_payload::() + && let LayersDrag::Guides(guides) = &*drag + { + guide_drop(ui, view, guides, item.node.id, r, &resp, out); + } +} + +/// Guide rows `guides` dragged over row `r` (`resp`) of layer `layer`: the row lights up, and the +/// release puts them on that layer (`guide.setLayer`, one undo step). +fn guide_drop(ui: &Ui, view: &View, guides: &[usize], layer: NodeId, r: egui::Rect, resp: &egui::Response, out: &mut Out) { + ui.painter().rect_stroke(r.shrink(1.0), 0.0, Stroke::new(2.0, view.t.accent), StrokeKind::Inside); + if resp.dnd_release_payload::().is_some() { + out.actions.push(("guide.setLayer".into(), json!({"indexes": guides, "layer": layer.0}))); + } } /// The widgets of row `item`. @@ -828,6 +855,13 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: let moving: Vec = match &*drag { LayersDrag::Rows(ids) => ids.clone(), LayersDrag::Art => view.sel.iter().copied().collect(), + LayersDrag::Guides(guides) => { + // Guides go to the layer of the row (the row's own, for a layer). + if let Some(layer) = if n.is_layer() { Some(n.id) } else { doc.layer_containing(n.id) } { + guide_drop(ui, view, guides, layer, r, resp, out); + } + return; + } }; // Never into itself or a row inside it. let around = doc.ancestry(n.id).unwrap_or_default(); @@ -842,7 +876,7 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: LayersDrag::Art => Place::Above, LayersDrag::Rows(_) if inside && rel >= 0.25 && (rel <= 0.75 || open) => Place::Inside, LayersDrag::Rows(_) if rel < 0.5 => Place::Above, - LayersDrag::Rows(_) => Place::Below, + LayersDrag::Rows(_) | LayersDrag::Guides(_) => Place::Below, }; let stroke = Stroke::new(2.0, view.t.accent); match place { @@ -862,6 +896,7 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: // The selection's top objects, as Arrange and Group take them. LayersDrag::Art => doc.paint_order(moving.iter().copied()).iter().map(|i| i.0).collect(), LayersDrag::Rows(ids) => ids.iter().map(|i| i.0).collect(), + LayersDrag::Guides(_) => return, }; out.actions.push(("layer.move".into(), json!({"ids": ids, "target": n.id.0, "place": place.id(), "copy": copy}))); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2f1be11d6..d50caddc2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -257,9 +257,10 @@ where it is greyed. The modifier keys still work while dragging (Cmd on a corner perspective, Cmd on a side shears). Ruler guides are layer objects: `guide.add` puts a guide on the current layer (or `layer`), where it shows, hides, -locks and is deleted with its layer and is listed in the Layers panel (a click on its row selects it); -`guide.setLayer {index?, layer}` moves guides to another layer, and `guide.list` gives each -guide's `layer`, `shown` and `editable`. +locks and is deleted with its layer and is listed in the Layers panel (a click on its row selects it, a drag onto +another layer's rows moves it there); `guide.setLayer {index?, indexes?, layer}` moves guides to another layer, and +`guide.list` gives each guide's `layer`, `shown` and `editable`. Guides in files saved before guides had layers go on +the top visible, unlocked layer when the document opens. Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A From 2d2e0c6992e238d483eae07df3a1c3465c96fb0a Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Sat, 10 Oct 2026 18:30:25 +0000 Subject: [PATCH 69/97] Docs: ruler guides on layers in the parity docs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- docs/target-app-parity.md | 1 + docs/ui-parity.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d428e05fd..cf7d8ecd5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -411,6 +411,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel. +- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides duplicated with their layer. - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). - **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 036208f5e..ce279c81d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -90,7 +90,8 @@ tools, never checked side by side) isn't captured row by row. | Snapping panel/popover consolidating the snap options (30.0) | missing | | | Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only | | Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451 | -| Ruler guides as Layers rows; Alt swaps a ruler guide's orientation while dragging | missing | | +| Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer | +| Alt swaps a ruler guide's orientation while dragging | missing | | | Global and video rulers | missing | menu stubs | ## Transform handles, bounding box, Free Transform From c183c47df3bd4ae944ec5cde463c4d7f7213e0e4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 05:12:24 +0700 Subject: [PATCH 70/97] Review: guides on hidden or locked layers, and through layer commands - Guides on a hidden or locked layer leave the selection (Selection::prune, run after every edit and selection change), their Layers rows neither select nor drag them, and guide.move / guide.remove / guide.setLayer refuse them; guide.setLayer refuses a locked target layer, as dragged rows are refused there. - A new guide goes where new art goes (the current layer, else the top shown and unlocked one; refused when there is none), so it never lands on a hidden layer and vanishes. - Layer commands keep guides with their layers: Duplicate (and Alt-drag copies of rows) copy a layer's guides onto the copy, Merge Selected and Flatten Artwork put the merged layers' guides on the merged layer and Flatten discards a hidden layer's. One shared doc predicate for "the layer a guide is on"; the deleted-layer cleanup moves into the doc. - Tests: engine tests for the above and their undo, a doc check that an old guide reads with no layer and none is written, and a headless Layers panel test of guide rows (select, drag to another layer, locked). - Translations for the new message; docs and a ROADMAP progress line. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/doc/src/lib.rs | 62 +++++++++++++++++++++++------ crates/doc/src/selection.rs | 7 +++- crates/engine/src/cmd/docmenu.rs | 36 ++++++++++++----- crates/engine/src/cmd/layer.rs | 12 ++++-- crates/engine/src/cmd/layerpanel.rs | 4 ++ crates/engine/src/tests_menucmds.rs | 58 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/panels/layers.rs | 45 +++++++++++++++++++-- docs/control-protocol.md | 6 ++- docs/target-app-parity.md | 2 +- 17 files changed, 206 insertions(+), 35 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7397cbdbe..28a477052 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/doc/src/lib.rs b/crates/doc/src/lib.rs index 3f4e6df9d..08567cda4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -995,32 +995,68 @@ impl Document { let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect; Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }) } + /// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has. + fn guide_layer(&self, g: &Guide) -> Option { + g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) + } /// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is /// visible? A guide whose layer is gone shows as one on no layer. pub fn guide_shown(&self, g: &Guide) -> bool { - g.layer.is_none_or(|l| self.node(l).is_none() || self.is_visible(l)) + self.guide_layer(g).is_none_or(|l| self.is_visible(l)) } /// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked? pub fn guide_editable(&self, g: &Guide) -> bool { - g.layer.is_none_or(|l| self.node(l).is_none() || self.is_editable(l)) + self.guide_layer(g).is_none_or(|l| self.is_editable(l)) + } + /// Put the ruler guides on no layer this document has (from files saved before guides had + /// layers, or of layers merged into `layer`) on layer `layer`. Returns how many. + pub fn rehome_guides(&mut self, layer: NodeId) -> usize { + let lost: Vec = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect(); + let mut n = 0; + for (g, lost) in self.guides.iter_mut().zip(lost) { + if lost { + g.layer = Some(layer); + n += 1; + } + } + n } /// Put the ruler guides on no layer (from files saved before guides had layers), or on one that /// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many. pub fn adopt_guides(&mut self) -> usize { - let Some(home) = self.default_layer() else { return 0 }; - let lost: Vec = self + self.default_layer().map_or(0, |home| self.rehome_guides(home)) + } + /// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in + /// step. Guides on no layer stay. Returns how many went. + pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize { + let gone: Vec = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect(); + self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false)) + } + /// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made + /// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies + /// the objects on it. Returns how many. + pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize { + let layers = |n: &Node| { + let mut out = vec![]; + n.walk(&mut |c| { + if c.is_layer() { + out.push(c.id); + } + }); + out + }; + let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect(); + let copies: Vec = self .guides .iter() - .enumerate() - .filter(|(_, g)| g.layer.is_none_or(|l| !self.node(l).is_some_and(Node::is_layer))) - .map(|(i, _)| i) + .filter_map(|g| { + let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1; + Some(Guide { layer: Some(to), ..g.clone() }) + }) .collect(); - for i in &lost { - if let Some(g) = self.guides.get_mut(*i) { - g.layer = Some(home); - } - } - lost.len() + let n = copies.len(); + self.guides.extend(copies); + n } /// The ruler guides on layer `layer` (not its sublayers'), by index. pub fn guides_on(&self, layer: NodeId) -> impl Iterator + '_ { diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 5c09d95e6..0275953a1 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -125,7 +125,8 @@ impl Selection { self.target = None; } self.slices.retain(|id| doc.is_slice(*id)); - self.guides.retain(|i| *i < doc.guides.len()); + // Guides on a hidden or locked layer can't stay selected. + self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g))); } /// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected /// (the art of its sublayers too), anything else is selected itself. @@ -230,6 +231,10 @@ mod tests { assert_eq!(d.adopt_guides(), 2); assert!(d.guides.iter().all(|g| g.layer == Some(layer))); assert_eq!(d.adopt_guides(), 0, "once"); + // A guide saved before guides had layers reads as one on no layer. + let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap(); + assert_eq!(old.layer, None); + assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written"); } #[test] diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 4282905f4..90251db4e 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -516,10 +516,12 @@ fn guide_add(s: &mut Session, p: &Value) -> Result { } None => None, }; + let st = s.doc()?; let layer = match id_param(p, "layer") { - Some(l) if s.doc()?.doc.node(l).is_some_and(Node::is_layer) => Some(l), + Some(l) if st.doc.node(l).is_some_and(Node::is_layer) => Some(l), Some(_) => return Err(bad("guide.add", "layer must name a layer or sublayer")), - None => s.doc()?.current_layer(), + // Where new art goes (refused when no layer is shown and unlocked), or that group's layer. + None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }), }; let i = s.edit("New Guide", |d, _| { d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) }); @@ -554,6 +556,16 @@ fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { Ok(i) } +/// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer. +fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + let i = guide_index(s, v, cmd)?; + let d = &s.doc()?.doc; + if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) { + return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); + } + Ok(i) +} + fn guide_select(s: &mut Session, p: &Value) -> Result { let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?; let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::>>()?; @@ -573,7 +585,7 @@ fn guide_select(s: &mut Session, p: &Value) -> Result { /// Delete ruler guide `index`, else the selected guides, in one undo step → {count}. pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { let gone: Vec = match p.get("index") { - Some(v) => vec![guide_index(s, Some(v), "guide.remove")?], + Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?], None => s.doc()?.selection.guides.clone(), }; if gone.is_empty() { @@ -589,15 +601,21 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { /// Put ruler guide `index`, else the selected guides, on layer `layer`, in one undo step. fn guide_set_layer(s: &mut Session, p: &Value) -> Result { - let layer = id_param(p, "layer").filter(|l| s.doc().is_ok_and(|st| st.doc.node(*l).is_some_and(Node::is_layer))); - let layer = layer.ok_or_else(|| bad("guide.setLayer", "layer must name a layer or sublayer"))?; + const C: &str = "guide.setLayer"; + let d = &s.doc()?.doc; + let layer = + id_param(p, "layer").filter(|l| d.node(*l).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "layer must name a layer or sublayer"))?; + // As objects dragged in the Layers panel: never onto a locked layer. + if super::layer::locked_within(d, layer) { + return Err(EngineError::Other("the layer or group is locked".into())); + } let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { - (Some(v), _) => vec![guide_index(s, Some(v), "guide.setLayer")?], - (None, Some(list)) => list.iter().take(10_000).map(|v| guide_index(s, Some(v), "guide.setLayer")).collect::>>()?, + (Some(v), _) => vec![editable_guide_index(s, Some(v), C)?], + (None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::>>()?, (None, None) => s.doc()?.selection.guides.clone(), }; if moving.is_empty() { - return Err(bad("guide.setLayer", "no guide selected (or give an index)")); + return Err(bad(C, "no guide selected (or give an index)")); } s.edit("Move Guide to Layer", |d, _| { for i in &moving { @@ -613,7 +631,7 @@ fn guide_set_layer(s: &mut Session, p: &Value) -> Result { /// Put ruler guide `index` at `pos`, else move (or copy) the selected guides, in one undo step. pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("index") { - let i = guide_index(s, Some(v), "guide.move")?; + let i = editable_guide_index(s, Some(v), "guide.move")?; let pos = f64_req(p, "pos", "guide.move")?; s.edit("Move Guide", |d, _| { if let Some(g) = d.guides.get_mut(i) { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 6905d2d2a..be88f9056 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -221,7 +221,7 @@ pub(crate) fn accepts(d: &Document, parent: Option, n: &Node) -> bool { } /// Whether `id` or a container around it is locked. -fn locked_within(d: &Document, id: NodeId) -> bool { +pub(crate) fn locked_within(d: &Document, id: NodeId) -> bool { d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked)) } @@ -463,8 +463,7 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result { d.remove(*id)?; } // The guides on the deleted layers go with them. - let orphans: Vec = d.guides.iter().enumerate().filter(|(_, g)| g.layer.is_some_and(|l| d.node(l).is_none())).map(|(i, _)| i).collect(); - d.retain_guides(sel, |i, _| !orphans.contains(&i)); + d.drop_guides_of_deleted_layers(sel); sel.prune(d); Ok(()) })?; @@ -490,6 +489,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result { let mut c = d.reid(&n); if n.is_layer() { c.name = Some(format!("{} copy", n.display_name())); + d.copy_layer_guides(&n, &c); } out.push(d.insert(par, idx + 1, c)?); } @@ -682,7 +682,11 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { for id in &ids { nodes.push(if copy { let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; - d.reid(&n) + let c = d.reid(&n); + if n.is_layer() { + d.copy_layer_guides(&n, &c); + } + c } else { Arc::unwrap_or_clone(d.remove(*id)?) }); diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index a655da4b0..f84cf3f48 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -263,6 +263,8 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { all.extend(taken); all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id))); *ch = all; + // The merged layers' guides go to it too. + d.rehome_guides(into); Ok(()) } @@ -311,6 +313,8 @@ fn flatten(s: &mut Session, p: &Value) -> Result { for h in &hidden { d.remove(*h)?; } + // Hidden layers are discarded with their guides. + d.drop_guides_of_deleted_layers(sel); merge_into(d, into, &others)?; sel.prune(d); Ok(()) diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 1ac208876..04f1e68a0 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -1015,6 +1015,64 @@ fn ruler_guides_live_on_layers() { assert_eq!(guides(&s).len(), 2); } +/// Guides on a hidden or locked layer leave the selection and can't be moved, deleted or moved to +/// another layer; new guides skip a locked current layer as new art does; no guide goes onto a +/// locked layer. +#[test] +fn guides_on_hidden_or_locked_layers_are_left_alone() { + let mut s = session(); + let first = s.doc().unwrap().current_layer().unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + let second = id_of(&s.execute("layer.new", &json!({})).unwrap()); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0, 1]})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [first.0], "locked": true})).unwrap(); + assert_eq!(selected_guides(&s), [1], "the locked layer's guide is deselected"); + assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([]), "and can't be selected"); + assert!(s.execute("guide.move", &json!({"index": 0, "pos": 50})).is_err()); + assert!(s.execute("guide.remove", &json!({"index": 0})).is_err()); + assert!(s.execute("guide.setLayer", &json!({"index": 0, "layer": second.0})).is_err()); + assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).is_err(), "not onto a locked layer"); + s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap(); + assert!(selected_guides(&s).is_empty(), "the hidden layer's guide is deselected"); + // Both layers hidden or locked: a new guide is refused, as new art is. + assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 10})).is_err()); + s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [first.0], "locked": false})).unwrap(); + // The current layer locked: the new guide goes on the top shown, unlocked layer. + s.execute("guide.add", &json!({"vertical": true, "pos": 20})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[2].layer, Some(first)); + assert_eq!(guides(&s).len(), 3); +} + +/// Guides follow their layers through Duplicate, Merge Selected and Flatten Artwork, in one undo +/// step each. +#[test] +fn guides_go_with_their_layers_when_duplicated_merged_and_flattened() { + let mut s = session(); + let first = s.doc().unwrap().current_layer().unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + let second = id_of(&s.execute("layer.new", &json!({})).unwrap()); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).map(|(_, g)| (g.vertical, g.pos)).collect::>(); + // Duplicate Layer copies its guides onto the copy. + let copy = s.execute("layer.duplicate", &json!({"ids": [second.0]})).unwrap()["ids"][0].as_u64().map(NodeId).unwrap(); + assert_eq!((on(&s, second), on(&s, copy)), (vec![(false, 300.0)], vec![(false, 300.0)])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 2); + // Merged into the first layer, the second layer's guide goes with its art. + s.execute("layer.merge", &json!({"ids": [second.0, first.0]})).unwrap(); + assert_eq!(on(&s, first), [(true, 200.0), (false, 300.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((on(&s, first), on(&s, second)), (vec![(true, 200.0)], vec![(false, 300.0)])); + // Flattened into the first layer, a hidden layer is discarded with its guides. + s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap(); + s.execute("layer.flatten", &json!({"id": first.0})).unwrap(); + assert_eq!(guides(&s), [(true, 200.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 2); +} + /// #451: art moved with the Selection tool lands flush on the artboard's edges and centre (Smart /// Guides). #[test] diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index cbfcfe728..ba350b177 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3660,6 +3660,7 @@ theme Appearance Erscheinungsbild @msg the image's pixels don't match its size die Pixel des Bilds passen nicht zu seiner Größe @msg the layer has no objects to clip by die Ebene hat keine Objekte, an denen beschnitten werden könnte @msg the layer or group is locked die Ebene oder Gruppe ist gesperrt +@msg the guide is on a hidden or locked layer die Hilfslinie liegt auf einer ausgeblendeten oder gesperrten Ebene @msg the target layer is locked or hidden die Zielebene ist gesperrt oder ausgeblendet @msg the layer to flatten into is hidden die Ebene, auf die reduziert werden soll, ist ausgeblendet @msg the object already has an opacity mask das Objekt hat bereits eine Deckkraftmaske diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 58260ddd7..07b19b0c8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3760,6 +3760,7 @@ axis Both Ambos @msg the image's pixels don't match its size los píxeles de la imagen no coinciden con su tamaño @msg the layer has no objects to clip by la capa no tiene objetos con los que recortar @msg the layer or group is locked la capa o el grupo está bloqueado +@msg the guide is on a hidden or locked layer la guía está en una capa oculta o bloqueada @msg the target layer is locked or hidden la capa de destino está bloqueada u oculta @msg the layer to flatten into is hidden la capa en la que acoplar está oculta @msg the object already has an opacity mask el objeto ya tiene una máscara de opacidad diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ae1b2a4df..72b974073 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3757,6 +3757,7 @@ axis Both Les deux @msg the image's pixels don't match its size les pixels de l’image ne correspondent pas à sa taille @msg the layer has no objects to clip by le calque n’a aucun objet servant à l’écrêtage @msg the layer or group is locked le calque ou le groupe est verrouillé +@msg the guide is on a hidden or locked layer le repère se trouve sur un calque masqué ou verrouillé @msg the target layer is locked or hidden le calque cible est verrouillé ou masqué @msg the layer to flatten into is hidden le calque de destination de l’aplatissement est masqué @msg the object already has an opacity mask l’objet a déjà un masque d’opacité diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8d8ee1a5b..695b75b45 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3760,6 +3760,7 @@ axis Both Entrambi @msg the image's pixels don't match its size i pixel dell'immagine non corrispondono alle sue dimensioni @msg the layer has no objects to clip by il livello non ha oggetti con cui ritagliare @msg the layer or group is locked il livello o il gruppo è bloccato +@msg the guide is on a hidden or locked layer la guida si trova su un livello nascosto o bloccato @msg the target layer is locked or hidden il livello di destinazione è bloccato o nascosto @msg the layer to flatten into is hidden il livello in cui appiattire è nascosto @msg the object already has an opacity mask l'oggetto ha già una maschera di opacità diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 07e37509b..b32c26d9a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3498,6 +3498,7 @@ theme Appearance 外観 @msg no selection bounds 選択範囲に境界がありません @msg the layer has no objects to clip by レイヤーにクリッピングマスクとして使えるオブジェクトがありません @msg the layer or group is locked レイヤーまたはグループがロックされています +@msg the guide is on a hidden or locked layer ガイドが非表示またはロックされたレイヤー上にあります @msg the target layer is locked or hidden ターゲットレイヤーがロックされているか非表示です @msg the layer to flatten into is hidden 統合先のレイヤーが非表示になっています @msg there are no objects to release レイヤーに分配するオブジェクトがありません diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4489f231a..be704caea 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3756,6 +3756,7 @@ axis Both Оба @msg the image's pixels don't match its size пиксели изображения не соответствуют его размеру @msg the layer has no objects to clip by в слое нет объектов для обрезки @msg the layer or group is locked слой или группа заблокированы +@msg the guide is on a hidden or locked layer направляющая находится на скрытом или заблокированном слое @msg the target layer is locked or hidden целевой слой заблокирован или скрыт @msg the layer to flatten into is hidden слой для объединения скрыт @msg the object already has an opacity mask у объекта уже есть непрозрачная маска diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e9a7c5472..6ce17e591 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3743,6 +3743,7 @@ axis Both Обидві @msg the image's pixels don't match its size пікселі зображення не відповідають його розміру @msg the layer has no objects to clip by у шарі немає об’єктів для обрізки @msg the layer or group is locked шар чи група заблоковані +@msg the guide is on a hidden or locked layer напрямна розташована на прихованому або заблокованому шарі @msg the target layer is locked or hidden цільовий шар заблокований або прихований @msg the layer to flatten into is hidden шар для об’єднання прихований @msg the object already has an opacity mask об’єкт вже має маску непрозорості. diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 9ac35b880..8cc08516a 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -560,13 +560,14 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { /// The row of ruler guide `i` on the layer of `item`: a guide mark and its name, the selection /// square when it is selected. A click selects the guide; Shift or Cmd adds it to (or takes it -/// from) the selected guides. +/// from) the selected guides. A guide on a hidden or locked layer can't be selected or dragged. fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { let (t, h) = (&view.t, view.h); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); let Some(g) = view.doc.guides.get(i) else { return }; let selected = view.guides.contains(&i); - if resp.hovered() { + let editable = view.doc.guide_editable(g); + if editable && resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); } ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); @@ -588,12 +589,12 @@ fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { ui.painter().rect_filled(q, 0.0, item.colour); ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside); } - if resp.clicked() { + if editable && resp.clicked() { let m = ui.input(|i| i.modifiers); out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command}))); } // Dragged: the selected guides when this is one of them, else this one, to another layer's row. - if resp.drag_started() { + if editable && resp.drag_started() { let guides = if selected { view.guides.clone() } else { vec![i] }; egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides)); } @@ -1512,6 +1513,42 @@ mod tests { assert_eq!(d.layers.len(), 1); } + /// A layer's ruler guides are rows in it, above its art: a click selects the guide, a drag onto + /// another layer's row moves it there, and a guide on a locked layer is left alone. + #[test] + fn guide_rows_select_and_move_their_guides() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})); + run(&mut app, "guide.add", json!({"vertical": true, "pos": 50})); + run(&mut app, "select.none", json!({})); + let second = NodeId(run(&mut app, "layer.new", json!({}))["id"].as_u64().unwrap()); + expand(&mut app, &json!({})).unwrap(); + let ctx = egui::Context::default(); + // Rows top down: Layer 2, Layer 1, its guide (no target circle), the rectangle. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 3); + let guide_row = egui::pos2(150.0, (circles[1].y + circles[2].y) / 2.0); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone(); + click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); + assert_eq!(guides(&app), [0], "its row selects it"); + // Dragged onto Layer 2's row, it goes on Layer 2, in one undo step. + let undo = app.session.active().unwrap().history.undo.len(); + drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[0].y), false); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.guides[0].layer, st.history.undo.len()), (Some(second), undo + 1)); + // Now under Layer 2: locked, its row neither selects nor drags it. + run(&mut app, "layer.setProps", json!({"ids": [second.0], "locked": true})); + assert!(guides(&app).is_empty(), "deselected with its layer locked"); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let guide_row = egui::pos2(150.0, (circles[0].y + circles[1].y) / 2.0); + click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); + assert!(guides(&app).is_empty()); + drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[2].y), false); + assert_eq!(app.session.active().unwrap().doc.guides[0].layer, Some(second)); + } + /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows /// holding selected art). fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d50caddc2..8ad7af87a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -259,8 +259,10 @@ perspective, Cmd on a side shears). Ruler guides are layer objects: `guide.add` puts a guide on the current layer (or `layer`), where it shows, hides, locks and is deleted with its layer and is listed in the Layers panel (a click on its row selects it, a drag onto another layer's rows moves it there); `guide.setLayer {index?, indexes?, layer}` moves guides to another layer, and -`guide.list` gives each guide's `layer`, `shown` and `editable`. Guides in files saved before guides had layers go on -the top visible, unlocked layer when the document opens. +`guide.list` gives each guide's `layer`, `shown` and `editable`. Guides on a hidden or locked layer leave the selection +and can't be picked, moved or deleted, and no guide goes onto a locked layer; Duplicate copies a layer's guides, Merge +Selected and Flatten Artwork keep them on the merged layer (Flatten discards a hidden layer's). Guides in files saved +before guides had layers go on the top visible, unlocked layer when the document opens. Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cf7d8ecd5..a766deb75 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -411,7 +411,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel. -- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides duplicated with their layer. +- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). - **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). From cee73d03a378ae37850bfb973256e68d60bdabb9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 04:19:04 +0700 Subject: [PATCH 71/97] #1025: .ai editing data keeps sublayers with global colours and pattern fills The editing-data reader stopped on `Xk`/`XK` (global process colours) and on `p`/`P` (pattern fills), so the file fell back to its PDF part, which has only the top-level layers. - `Xk`/`XK` read like `Xx`: a global swatch (not a spot ink), linked at its tint. - `%AI3_BeginPattern` sections are read by a reader of their own into pattern swatches (tile from `(name) llx lly urx ury`, art in pattern space); `p`/`P` fill or stroke with them, placed by the operator's matrix. A pattern it can't read whole still makes a fill with it unreadable, as before. - Tests on synthetic editing data; fuzz tokens and fixture extended. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/tests/import_fuzz.rs | 18 ++- crates/eps/src/import/ai/lex.rs | 10 ++ crates/eps/src/import/ai/paint.rs | 25 +++- crates/eps/src/import/ai/read.rs | 167 +++++++++++++++++++++++--- crates/eps/src/import/ai/tests.rs | 86 +++++++++++++ crates/eps/src/import/tests_native.rs | 27 +++++ docs/file-format-parity.md | 7 +- docs/gaps.md | 5 +- 9 files changed, 321 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 28a477052..04c2c9753 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index bbab8fa3f..0a6407b28 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1702,6 +1702,9 @@ fn ascii85(data: &[u8]) -> String { out } +/// A pattern's definition in editing data: its name and tile, its art on `%_` lines, its end. +const PATTERN: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n%_0 A\n%_0 Ae\n%_u\n%_0 0 m\n%_0 6 L\n%_6 6 L\n%_6 0 L\n%_n\n%_0 0 0 1 K\n%_1 J 1 w\n%_3 3 m\n%_3 3 L\n%_S\n%_U\n%_9 () XW\nE\n%AI3_EndPattern\n"; + /// The editing data of a file with every sort of object the layers reader knows, and some it doesn't. fn editing_text() -> String { let square = |x: u32| format!("0 0 1 0 k\n{x} 10 m\n{} 10 L\n{} 14 L\n{x} 14 L\nf", x + 4, x + 4); @@ -1724,13 +1727,16 @@ fn editing_text() -> String { "0 Xw".into(), "1 0 0 0 1 0 Bg".into(), "0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(), + "0 0 0 1 (Black global) 0.4 0 Xk".into(), + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(), + square(60), ] .join("\n"); let layer = |name: &str, visible: u8, body: &str| { format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n") }; format!( - "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{}{}%%Trailer\n", + "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{PATTERN}{}{}%%Trailer\n", layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))), layer("Two", 0, &square(80)) ) @@ -1980,6 +1986,16 @@ fn arb_ai_token() -> impl Strategy { "/Binary : /ASCII85Decode ,", "~>", "p", + "P", + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", + "(Dots) 0 0 1 1 0 0 0 0 0 [0 0 0 0 1e308 0] P", + "Xk", + "XK", + "0 0 0 1 (Global) 0.4 0 Xk", + "1 0.5 0 (Global) 0 1 XK", + "E", + "\n%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n", + "\n%AI3_EndPattern\n", "To", "frobnicate", "\n%AI5_BeginLayer\n", diff --git a/crates/eps/src/import/ai/lex.rs b/crates/eps/src/import/ai/lex.rs index 25f757b7f..b30481b52 100644 --- a/crates/eps/src/import/ai/lex.rs +++ b/crates/eps/src/import/ai/lex.rs @@ -81,6 +81,16 @@ impl<'a> Lexer<'a> { rest.get(..n).unwrap_or_default() } + /// Where the next token is looked for. + pub fn offset(&self) -> usize { + self.pos + } + + /// The data from `from` to `to` (empty where that isn't in it). + pub fn slice(&self, from: usize, to: usize) -> &'a [u8] { + self.src.get(from..to).unwrap_or_default() + } + /// Skip past the next `needle` (the end of data that isn't tokens) → what was skipped. pub fn skip_past(&mut self, needle: &[u8]) -> &'a [u8] { let rest = self.rest(); diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index 2358b65f8..af6f86e22 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -3,7 +3,9 @@ //! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint //! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK //! values its equivalent), `Xx`/`XX` a named colour of either model -//! (`c m y k [r g b] (name) tint type`). Lower case paints fills, upper case strokes. +//! (`c m y k [r g b] (name) tint type`), `Xk`/`XK` a global process colour (its operands as +//! `Xx`'s: a global swatch that isn't an ink of its own). Lower case paints fills, upper case +//! strokes. //! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6] //! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`. //! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d @@ -28,6 +30,8 @@ pub(super) struct Named { /// The full colour. pub color: Color, pub tint: f32, + /// An ink of its own (a spot colour), not a global process colour. + pub spot: bool, } /// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named @@ -48,6 +52,7 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { } "x" => named(vals, Some(false)), "xx" => named(vals, None), + "xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))), _ => None, } } @@ -72,7 +77,7 @@ fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k)) }; let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint }; - Some((paint, Some(Named { name, color, tint }))) + Some((paint, Some(Named { name, color, tint, spot: true }))) } /// The colour of a gradient stop's colour values and style. @@ -162,7 +167,7 @@ pub(super) struct Instance { hilight: Vec2, } -fn affine(n: &[f64]) -> Option { +pub(super) fn affine(n: &[f64]) -> Option { let [a, b, c, d, e, f] = n else { return None }; let m = Affine::new([*a, *b, *c, *d, *e, *f]); (m.as_coeffs().iter().all(|v| v.is_finite())).then_some(m) @@ -247,6 +252,20 @@ mod tests { assert!((named.tint - 0.6).abs() < 1e-6); assert!(matches!(p, Paint::Solid { swatch: Some(_), .. })); assert!(color_op("k", &nums(&[1.0])).is_none()); + // A global process colour: CMYK (type 0) or RGB (type 1), not an ink. + let mut v = nums(&[0.0, 0.0, 0.0, 1.0]); + v.extend([V::Str(b"K100".to_vec()), V::Num(0.4), V::Num(0.0)]); + let (p, named) = color_op("XK", &v).unwrap(); + let named = named.unwrap(); + assert_eq!((named.name.as_str(), named.color, named.spot), ("K100", Color::cmyk(0.0, 0.0, 0.0, 1.0), false)); + assert!((named.tint - 0.6).abs() < 1e-6); + assert!( + matches!(p, Paint::Solid { swatch: Some(s), tint, color: Color::Cmyk { k, .. } } if s == "K100" && (tint - 0.6).abs() < 1e-6 && (k - 0.6).abs() < 1e-6) + ); + let mut v = nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.5, 0.0]); + v.extend([V::Str(b"Orange".to_vec()), V::Num(0.0), V::Num(1.0)]); + let (p, _) = color_op("Xk", &v).unwrap(); + assert_eq!(p.color(), Some(Color::rgb(1.0, 0.5, 0.0))); } #[test] diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index b018f68af..cb45d9acf 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -15,10 +15,15 @@ //! - Art with an appearance the format can't write as plain art (several fills or strokes, //! effects, brushes) comes first as that look, drawn (a group), then the object itself on `%_` //! lines and `1 (style) XW`: the drawn look is kept, named after the object. +//! - Patterns: `%AI3_BeginPattern: (name)`, `(name) llx lly urx ury` (the tile, in the pattern's +//! space, y up), the tile's art (on `%_` lines in newer files), `E`, `%AI3_EndPattern`; each is +//! read by a reader of its own and becomes a pattern swatch. A fill or stroke with one: +//! `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`), the matrix +//! taking the pattern's space to art space (the other operands are what it is made of). //! -//! Sections the reader doesn't need (patterns, brushes, symbols, styles, swatches, filters) are -//! skipped; type, symbols, pattern fills, placed files, legacy type and operators it doesn't know -//! are errors. +//! Sections the reader doesn't need (brushes, symbols, styles, swatches, filters) are skipped; +//! type, symbols, fills with a pattern it can't read, placed files, legacy type and operators it +//! doesn't know are errors. use std::collections::{BTreeSet, HashMap, HashSet}; use std::rc::Rc; @@ -30,7 +35,7 @@ use vectorcraft_color::swatch::Swatch; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{ Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin, - Node, NodeId, NodeKind, StrokeLayer, + Node, NodeId, NodeKind, PatternDef, StrokeLayer, }; use vectorcraft_geom::{FillRule, PathData}; @@ -51,6 +56,10 @@ const MAX_POINTS: usize = 4_000_000; const MAX_PIXELS: u64 = 1 << 25; /// Largest side of the document and its artboards (points). const MAX_SIDE: f64 = 1e6; +/// Art space (y up) → a pattern's space in the document's terms (y down). +const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); +/// Most patterns read (others can't fill art). +const MAX_PATTERNS: usize = 4096; /// What the editing data holds: the document of its art, and where its art space is. #[derive(Debug)] @@ -197,6 +206,16 @@ struct Frame { clips: Vec, } +/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands +/// before its first operator. +#[derive(Debug, Default)] +struct PatternHead { + taken: bool, + name: String, + /// The tile in the pattern's space (y up). + tile: Option, +} + /// An image's operands, from `XN` and before `XI`. #[derive(Debug, Default)] struct Raster { @@ -260,6 +279,10 @@ struct Reader<'a> { /// The artboard's size and centre in files without artboards or crop marks. art_size: Option<(f64, f64)>, template_center: Option<(f64, f64)>, + /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). + pattern: Option, + /// The tile's top left (y down) of each pattern read, by name. + pattern_tiles: HashMap, } /// Operators that change nothing the reader keeps. @@ -377,6 +400,8 @@ impl<'a> Reader<'a> { slot_names: HashMap::new(), art_size: None, template_center: None, + pattern: None, + pattern_tiles: HashMap::new(), } } @@ -429,6 +454,12 @@ impl<'a> Reader<'a> { let line = String::from_utf8_lossy(c); let line = line.trim_end(); let m = marker(line); + // A pattern's definition (one inside another's isn't read). + if m == "BeginPattern" && self.pattern.is_none() { + let section = self.section("EndPattern")?; + self.define_pattern(section); + return Ok(()); + } if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) { return self.skip_to(end); } @@ -481,10 +512,18 @@ impl<'a> Reader<'a> { /// Skip to the comment with marker `end` (sections of the same kind nested in it count, and /// versioned content in an alternate). fn skip_to(&mut self, end: &str) -> Result<(), String> { + self.section(end).map(|_| ()) + } + + /// [`Self::skip_to`] → what was skipped, up to the comment that ends it. + fn section(&mut self, end: &str) -> Result<&'a [u8], String> { let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b); let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin }; + let start = self.lex.offset(); let mut depth = 0usize; - while let Some(t) = self.lex.next_token() { + loop { + let before = self.lex.offset(); + let Some(t) = self.lex.next_token() else { break }; if let Tok::Comment(c) = t.tok { let line = String::from_utf8_lossy(c); let m = marker(&line); @@ -492,7 +531,7 @@ impl<'a> Reader<'a> { depth += 1; } else if m == end { if depth == 0 { - return Ok(()); + return Ok(self.lex.slice(start, before)); } depth -= 1; } @@ -501,6 +540,58 @@ impl<'a> Reader<'a> { Err(format!("its section ending with `%{end}` doesn't end")) } + /// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its + /// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't + /// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art + /// filled with it can't be read. + fn define_pattern(&mut self, section: &[u8]) { + if self.pattern_tiles.len() >= MAX_PATTERNS { + return; + } + let mut sub = Reader::new(section); + sub.pattern = Some(PatternHead::default()); + // The gradients defined so far, lent (and those it defines kept). + sub.gradients = std::mem::take(&mut self.gradients); + sub.nodes = self.nodes; + let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); + self.nodes = sub.nodes; + self.gradients = std::mem::take(&mut sub.gradients); + let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1; + let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return }; + let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else { + return; + }; + let art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); + let mut def = PatternDef::new(&head.name, art); + def.tile = FLIP.transform_rect_bbox(tile); + if self.pattern_tiles.insert(def.name.clone(), def.tile.origin()).is_some() { + self.doc.patterns.retain(|p| p.name != def.name); + } + self.doc.patterns.push(def); + for (key, blob) in sub.doc.images { + self.doc.images.entry(key).or_insert(blob); + } + self.spots.extend(sub.spots); + self.cmyk_colors += sub.cmyk_colors; + self.rgb_colors += sub.rgb_colors; + for w in &sub.warnings { + self.warn(w); + } + } + + /// In a pattern's definition, the operands of its first operator start with its head, + /// `(name) llx lly urx ury`: taken off them. + fn take_head(&mut self, mut vals: Vec) -> Vec { + let Some(head) = self.pattern.as_mut().filter(|h| !h.taken) else { return vals }; + head.taken = true; + let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals }; + let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default()); + let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals }; + head.name = name; + head.tile = Some(Rect::new(*x0, *y0, *x1, *y1)); + vals.split_off(at + 5) + } + // ---- frames ---------------------------------------------------------------------------- fn open(&mut self, kind: Kind, hidden: bool) -> Result<(), String> { @@ -682,6 +773,7 @@ impl<'a> Reader<'a> { self.after_object = false; } let vals = self.operands(); + let vals = self.take_head(vals); if let Some(d) = self.gradient_def.as_mut() { match w { "Bs" => d.stop(&vals, &mut self.spots), @@ -728,8 +820,10 @@ impl<'a> Reader<'a> { self.paint(&op, hidden, true)?; } // Colours. - "g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" => self.color(w, &vals), - "p" | "P" => self.unreadable("pattern fills"), + "g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" => self.color(w, &vals), + "p" | "P" => self.pattern_paint(w, &vals), + // A pattern's definition ends. + "E" if self.pattern.is_some() => self.done = true, "O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0), "R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0), "XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero }, @@ -856,6 +950,9 @@ impl<'a> Reader<'a> { /// A text object (`/AI11Text`): an empty group standing for it, named after its story. fn text_slot(&mut self, o: &Obj) -> Result<(), String> { + if self.pattern.is_some() { + self.unreadable("type in a pattern"); + } let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32); let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0)); let gs = self.gs.clone(); @@ -893,7 +990,7 @@ impl<'a> Reader<'a> { self.warn(NON_NATIVE_ART_UNREAD); return Ok(()); } - let target = self.ensure_space().transform_rect_bbox(art_box); + let target = self.space().transform_rect_bbox(art_box); let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() }; let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) { Ok(r) => r, @@ -1021,6 +1118,34 @@ impl<'a> Reader<'a> { }); } + /// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's): + /// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't + /// read can't be drawn. + fn pattern_paint(&mut self, w: &str, vals: &[V]) { + // In the setup it paints nothing (and the art's space isn't fixed yet). + if self.frames.is_empty() { + return; + } + let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); + let Some(origin) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).map(|p| p.to_vec2()) else { + self.unreadable("pattern fills"); + return; + }; + let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); + // The tile's art (y down from the tile's top left) → the pattern's space → art space. + let xf = self.space() * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate(origin); + if xf.determinant().abs() < 1e-12 || !xf.as_coeffs().iter().all(|v| v.is_finite()) { + self.unreadable("pattern fills"); + return; + } + let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf }; + if w == "p" { + self.gs.fill = p; + } else { + self.gs.stroke = p; + } + } + fn color(&mut self, w: &str, vals: &[V]) { let Some((p, named)) = paint::color_op(w, vals) else { return }; match p.color() { @@ -1031,7 +1156,7 @@ impl<'a> Reader<'a> { if let Some(n) = named { self.spots.push(n); } - if w.chars().all(|c| c.is_ascii_lowercase()) || w == "Xa" || w == "Xx" { + if w.ends_with(|c: char| c.is_ascii_lowercase()) { self.gs.fill = p; } else { self.gs.stroke = p; @@ -1165,7 +1290,7 @@ impl<'a> Reader<'a> { if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) { close(&mut bp); } - let to_doc = self.ensure_space(); + let to_doc = self.space(); let path = PathData::from_bezpath(&(to_doc * bp)); let gs = self.gs.clone(); let (fill, stroke) = match op.to_ascii_lowercase().as_str() { @@ -1200,7 +1325,7 @@ impl<'a> Reader<'a> { } fn set_instance(&mut self, i: &Instance) { - let to_doc = self.ensure_space(); + let to_doc = self.space(); match i.paint(&self.gradients, to_doc) { Some(p) if i.stroke => self.pending_stroke = Some(p), Some(p) => self.pending_fill = Some(p), @@ -1286,9 +1411,8 @@ impl<'a> Reader<'a> { } // The matrix maps pixels (y down) into art space with its y flipped. let [a, b, c, d, tx, ty] = m.as_slice() else { return Ok(()) }; - let to_doc = self.ensure_space(); - let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); - let xf = to_doc * flip * Affine::new([*a, *b, *c, *d, *tx, -*ty]); + let to_doc = self.space(); + let xf = to_doc * FLIP * Affine::new([*a, *b, *c, *d, *tx, -*ty]); if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { return Ok(()); } @@ -1326,6 +1450,12 @@ impl<'a> Reader<'a> { m } + /// Art space → where the art being read goes: the document, or while a pattern's definition is + /// read, the pattern's space (y down). + fn space(&mut self) -> Affine { + if self.pattern.is_some() { FLIP } else { self.ensure_space() } + } + fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 { self.warnings.push(w.to_string()); @@ -1374,7 +1504,12 @@ impl<'a> Reader<'a> { } for n in &self.spots { if doc.swatch(&n.name).is_none() { - doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: true }); + doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot }); + } + } + for name in doc.patterns.iter().map(|p| p.name.clone()).collect::>() { + if doc.swatch(&name).is_none() { + doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false }); } } let mut warnings = self.warnings; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 25ded7b3b..70c07b4cc 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -132,6 +132,92 @@ fn paths_colours_and_strokes() { assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some()); } +/// `Xk` and `XK` are global process colours: a name, a tint and whether the colour is RGB, as +/// `Xx` has them (#1025). They come in as global swatches that aren't inks, linked at their tint. +#[test] +fn global_process_colours_are_global_swatches_at_their_tint() { + let art_ = format!("0 0 0 1 (Black global) 0.4 0 Xk\n0 0 0 0 1 0.5 0 (Brand orange) 0 1 XK\n{}b\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let doc = read_ok(&layer(&art_)).doc; + let a = art(&doc); + let Paint::Solid { swatch, tint, color } = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) }; + assert_eq!(swatch.as_deref(), Some("Black global")); + assert!((tint - 0.6).abs() < 1e-6 && matches!(color, Color::Cmyk { k, .. } if (k - 0.6).abs() < 1e-6), "{tint} {color:?}"); + let stroke = a[0].appearance.stroke().unwrap().paint.clone(); + assert!( + matches!(&stroke, Paint::Solid { swatch: Some(s), tint, color } if s == "Brand orange" && *tint == 1.0 && *color == Color::rgb(1.0, 0.5, 0.0)), + "{stroke:?}" + ); + for name in ["Black global", "Brand orange"] { + let sw = doc.swatch(name).unwrap(); + assert!(sw.global && !sw.spot, "{sw:?}"); + } + assert_eq!(doc.swatch("Black global").unwrap().paint.color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the swatch is the full colour"); +} + +/// A pattern's definition (`%AI3_BeginPattern`): its name and tile (8 points square here, y up), +/// then its art on `%_` lines (a group of the tile's unpainted box and a dot, a round-capped +/// stroke of no length), `E`. +const DOTS: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 8 8\n%_0 A\n%_0 Xw\n%_0 Ae\n%_u\n%_0 O\n%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L\n%_n\n\ + %_0 R\n%_0 0 0 1 K\n%_1 J 0 j 2 w 4 M []0 d\n%_4 4 m\n%_4 4 L\n%_S\n%_U\n%_/ArtDictionary :\n%_;\n%_\n%_9 () XW\nE\n%AI3_EndPattern\n"; + +/// Patterns are read into pattern swatches, and `p` fills with one (#1025): the art keeps its +/// layers, the fill places the tiles by its matrix. +#[test] +fn patterns_are_pattern_swatches_and_fill_with_them() { + let sub = |name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{art}LB\n%AI5_EndLayer--\n"); + let body = format!( + "{DOTS}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Ground) Ln\n{}{}LB\n%AI5_EndLayer--\n", + sub("Lawn", &format!("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0))), + sub("Paving", &format!("(Dots) 10 20 2 2 0 0 0 0 0 [2 0 0 2 10 20] P\n{}S\n", rect_path(90.0, 20.0, 150.0, 80.0))), + ); + let s = read_ok(&body); + let doc = &s.doc; + let ground = &doc.layers[0]; + let subs: Vec<_> = ground.children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(subs, ["Lawn", "Paving"]); + let def = doc.pattern("Dots").unwrap(); + assert_eq!(def.tile, Rect::new(0.0, -8.0, 8.0, 0.0), "the tile, y down"); + assert_eq!(def.art.len(), 1, "the tile's group"); + let tile_box = &def.art[0].children().unwrap()[0]; + assert_eq!(tile_box.geometric_bounds(), Some(def.tile), "the tile's box is where the tile is"); + assert_eq!(def.art[0].children().unwrap().len(), 2); + assert!(matches!(&doc.swatch("Dots").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Dots")); + let lawn = &ground.children().unwrap()[0].children().unwrap()[0]; + let Paint::Pattern { pattern, xf } = fill(lawn) else { panic!("{:?}", fill(lawn)) }; + assert_eq!(pattern, "Dots"); + // The tile's top left (0, 8) in art space is (0, 92) on the page (crop marks 100 high); a dot + // centre (4, 4) is at (4, 96). + assert_eq!(xf * Point::ZERO, Point::new(0.0, 92.0)); + assert_eq!(xf * Point::new(4.0, 4.0), Point::new(4.0, 96.0)); + // A stroke with it, scaled twice and moved by (10, 20): the tile's top left is at (10, 36) in + // art space. + let paving = &ground.children().unwrap()[1].children().unwrap()[0]; + let Some(Paint::Pattern { xf, .. }) = paving.appearance.stroke().map(|st| st.paint.clone()) else { panic!() }; + assert_eq!(xf * Point::ZERO, Point::new(10.0, 64.0)); + assert!(s.warnings.is_empty(), "{:?}", s.warnings); +} + +/// A pattern whose definition it can't read whole isn't kept: a fill with it is something the file +/// has that isn't read, where it shows. Its definition costs nothing else. +#[test] +fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() { + let unread = "%AI3_BeginPattern: (Old)\n(Old) 0 0 8 8 [\n(0 O 0 R 0 g) @\n(\n0 0 m 8 8 L S\n) &\n] E\n%AI3_EndPattern\n\ + %AI3_BeginPattern: (Odd)\n(Odd) 0 0 8 8\n%_U\nE\n%AI3_EndPattern\n%AI3_BeginPattern: (Flat)\n(Flat) 0 0 8 0\n%_0 0 m\n%_8 0 L\n%_S\nE\n%AI3_EndPattern\n"; + let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. }))); + for name in ["Old", "Odd", "Flat"] { + let e = read( + stream(&format!("{unread}{}", layer(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0))))) + .as_bytes(), + ) + .unwrap_err(); + assert!(e.contains("pattern fills"), "{name}: {e}"); + } + // Type in a pattern isn't read either. + let typed = "%AI3_BeginPattern: (Words)\n(Words) 0 0 8 8\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\nE\n%AI3_EndPattern\n"; + assert!(read_ok(&format!("{typed}{}", layer(""))).doc.patterns.is_empty()); +} + #[test] fn compound_paths_and_clipping() { let art_ = format!( diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 312f1ddb8..bd8e4c32f 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -543,6 +543,33 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() { assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err()); } +/// A global process colour (`Xk`) or a pattern fill on a sublayer no longer sends a `.ai` to its +/// PDF part, which has only the top-level layers (#1025). +#[test] +fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() { + // The PDF part's yellow square, in a global yellow at full tint, on a sublayer; a hidden + // sublayer with a pattern fill (the page doesn't have it). + let yellow = "0 0 1 0 (Yellow global) 0 0 Xk\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"; + let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n"; + let patterned = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf"; + let ground = layer("Ground", true, &(layer("Lawn", true, yellow) + &layer("Paving", false, patterned))); + let art = native(&format!("{dots}{ground}")); + let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false); + assert_eq!(names(&d), ["Ground"], "{warnings:?}"); + let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| (l.name.clone().unwrap_or_default(), kinds(l))).collect(); + assert_eq!(subs, [("Lawn".to_string(), vec!["path"]), ("Paving".to_string(), vec!["path"])]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(d.swatch("Yellow global").is_some_and(|s| s.global && !s.spot)); + assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some()); + // Without a PDF part, the pattern fill shows. + let shown = native(&format!("{dots}{}", layer("Ground", true, &layer("Lawn", true, patterned)))); + let (d, notes) = crate::ai_alone(&private(&shown)).unwrap(); + let lawn = &d.layers[0].children().unwrap()[0]; + assert_eq!((lawn.name.as_deref(), kinds(lawn)), (Some("Lawn"), vec!["path"]), "{notes:?}"); + let fill = lawn.children().unwrap()[0].appearance.fill().map(|f| f.paint.clone()); + assert!(matches!(&fill, Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots"), "{fill:?}"); +} + #[test] fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() { // A 20-point square that the page doesn't draw: 4% of the page, and an object all the same. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index de29c4679..d9019c75e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -35,14 +35,14 @@ private editing data. VectorCraft: - **Known failures in users' files:** layer structure flattened or multiplied (#951: 109 layers instead of 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects, - brushes, symbols and pattern fills come in as their drawn look (#637), legacy `.ai` without the prolog not - read. + brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025), legacy `.ai` + without the prolog not read. - Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible only). Closing the open-side issues: 10–20 h with users' files. ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format @@ -78,4 +78,5 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | `.ai` editing data: global process colours and pattern fills read, so they no longer drop sublayers (#1025) | | 2026-10-10 | major | First checklist; moved the `.ai` editing-data scope from the ROADMAP's "Out of scope" here; added the user-reported `.ai` failures | diff --git a/docs/gaps.md b/docs/gaps.md index 07b16be3e..8a81e01f7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -55,8 +55,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead of 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction - lines (#779), open speed on large files (#758), legacy `.ai` without the prolog, live effects, brushes, symbols - and pattern fills as live objects (#637). Writing Illustrator's own editing data is out of scope + lines (#779), open speed on large files (#758), legacy `.ai` without the prolog, live effects, brushes and + symbols as live objects (#637; pattern fills and global colours are read since #1025). Writing Illustrator's own editing data is out of scope (undocumented); Save As `.ai` stays PDF-compatible. - **Evidence:** the issues above; 28 of the 350 issues are about `.ai`/EPS (about 22 people, the most common single format theme), 20 fixed, and the 8 open ones are about structure (layers, guides, speed, live objects), not missing @@ -200,6 +200,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | G9: `.ai` pattern fills and global colours read from the editing data (#1025) | | 2026-10-10 | minor | G3: initial Revolve landed (#846), 45–75 h left | | 2026-10-10 | minor | G9 evidence counted from the tracker (28 `.ai`/EPS issues, 20 fixed); impact narrowed to handing files back | | 2026-10-10 | minor | Alpha blockers checked against the core-workflow gate: none | From 2048084396df25e3171d27834d559c7f6dbc5f03 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 04:29:46 +0700 Subject: [PATCH 72/97] #1027: PostScript-form .ai files open through their layers A .ai in PostScript form went only to the PostScript interpreter, which stopped on the first operator its missing prolog would define (`Adobe_packedarray`, `Lb`, `Np`): files from Rhino and other CAD apps name the procsets without including them. - Such a program is its own editing data: when it has layers (`%AI5_BeginLayer`) and no private data, the editing-data reader reads it, compared with the page when the program runs, from the layers alone when it can't be run. If the layers can't be read either, the error says why. - The reader takes the legacy ten-operand `Lb`, ignores flatness `i`, and uses the bounding box as the page when nothing else says where it is. - Tests on synthetic files; fuzz fixture and hostile-program layers added. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md # docs/file-format-parity.md # docs/gaps.md --- ROADMAP.md | 2 +- .../engine/src/cmd/fileio/tests_epsimport.rs | 9 +++ crates/engine/tests/import_fuzz.rs | 41 +++++++++- crates/eps/src/import/ai/mod.rs | 5 ++ crates/eps/src/import/ai/read.rs | 37 ++++++--- crates/eps/src/import/mod.rs | 23 ++++-- crates/eps/src/import/native.rs | 43 +++++++++-- crates/eps/src/import/tests_illustrator.rs | 77 ++++++++++++++++++- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- docs/file-format-parity.md | 9 ++- docs/gaps.md | 5 +- 17 files changed, 225 insertions(+), 40 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 04c2c9753..f404f12a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs index 0e23dfed2..1f7488cf8 100644 --- a/crates/engine/src/cmd/fileio/tests_epsimport.rs +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -108,6 +108,15 @@ showpage let inner = outer.children().map(|c| (c.len(), c[1].children().map(Vec::len))).unwrap(); assert!(matches!(outer.kind, vectorcraft_doc::NodeKind::Group { clip: false, .. }), "{:?}", outer.kind); assert_eq!(inner, (2, Some(2))); + // One that names the prolog defining its operators without including it (as other apps write + // the format) opens with the layers its program has (#1027). + let named = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%DocumentNeededResources: procset Adobe_IllustratorA_AI3 1.0 0\n%AI3_TemplateBox: 50 50 50 50\n%%EndComments\n\ + %%BeginProlog\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 2 0 0 0 Lb\n(PLAN::Walls) Ln\n0 0 0 1 K\n0 i 1 w\n10 10 m\n90 90 L\nS\nLB\n%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n"; + let r = open(&mut s, "cad.ai", named).unwrap(); + assert_eq!(r["warnings"].as_array().map_or(0, Vec::len), 0, "{r}"); + let d = s.doc().unwrap().doc.clone(); + assert_eq!((d.layers.len(), d.layers[0].name.as_deref(), objects(&s)), (1, Some("PLAN::Walls"), 1)); // A .ait one opens as a new untitled document. let r = open(&mut s, "legacy.ait", ps).unwrap(); assert!(r["title"].as_str().unwrap().starts_with("Untitled"), "{r}"); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 0a6407b28..ba7f6fe15 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1280,11 +1280,17 @@ proptest! { /// Hostile PostScript programs: operators in any order with any operands, opened and placed; /// as files in the legacy Illustrator format too (the creator line is what turns on its - /// documented `u` … `U` groups), the groups unbalanced, deep and among clips. + /// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which + /// the editing data reader reads from the program too. #[test] - fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::()) { + fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::(), layers in any::()) { let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" }; - let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{}\nshowpage\n", tokens.join(" ")); + let (open, close) = if layers { + ("/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def\n%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(L) Ln\n", "\nLB\n%AI5_EndLayer--") + } else { + ("", "") + }; + let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{open}{}{close}\nshowpage\n", tokens.join(" ")); survive("hostile PostScript", || vectorcraft_engine::cmd::fileio::load("x.eps", ps.as_bytes()).ok().map(|l| l.doc))?; let r = catch_quiet(|| { let mut s = rich_session(); @@ -1878,6 +1884,35 @@ proptest! { } } +/// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named, +/// not included, so its program can't be run and its layers are read from it. +const NAMED_PROLOG_AI: &str = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 200 100\n%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n\ + %%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\nAdobe_packedarray /initialize get exec\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(A::B) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M [2 1]0 d\n0 D\n10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\n\ + u\n0.5 g\n20 20 m\n40 20 L\n40 40 L\nf\nU\n%AI5_BeginLayer\n0 1 1 1 0 0 3 0 0 0 Lb\n(Sub) Ln\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\ + %%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"; + +/// The fixture, as it is, opens with its layer and sublayer. +#[test] +fn the_named_prolog_fixture_opens_with_its_layers() { + let l = vectorcraft_engine::cmd::fileio::load("x.ai", NAMED_PROLOG_AI.as_bytes()).unwrap(); + let names: Vec<_> = l.doc.layers.iter().map(|l| l.name.clone()).collect(); + assert_eq!(names, [Some("A::B".to_string())], "{:?}", l.warnings); + assert_eq!(l.doc.layers[0].children().unwrap().len(), 3); +} + +proptest! { + #![proptest_config(config())] + + /// A `.ai` in PostScript form whose prolog is named, not included, damaged: read from its + /// layers, or refused. + #[test] + fn ps_form_ai_never_panics(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '%', 'u', 'U', 'L', 'b', 'B', 'i', 'A', 'K'])), 0..12)) { + let text = mutate_text(NAMED_PROLOG_AI, cut, &edits); + survive("mutated PostScript-form .ai", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?; + } +} + /// The operators, operands and section comments of hostile editing data. fn arb_ai_token() -> impl Strategy { prop_oneof![ diff --git a/crates/eps/src/import/ai/mod.rs b/crates/eps/src/import/ai/mod.rs index 489fb1ac0..c753fc724 100644 --- a/crates/eps/src/import/ai/mod.rs +++ b/crates/eps/src/import/ai/mod.rs @@ -52,6 +52,11 @@ pub fn eps_data(ps: &[u8]) -> Option, String>> { Some(eps_section(section)) } +/// Does the EPS `ps` carry editing data ([`eps_data`] isn't `None`)? +pub fn has_eps_data(ps: &[u8]) -> bool { + find(ps, EPS_BEGIN).is_some() +} + /// The editing data between the EPS markers: header comments, then the data (encoded or not). fn eps_section(section: &[u8]) -> Result, String> { let mut header = Vec::new(); diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index cb45d9acf..6d4f2aa4c 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -2,14 +2,17 @@ //! //! - Art space has y up; the first artboard's top left corner is the document's origin. //! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b … -//! dimPercent … Lb`, `(name) Ln`, its art, `LB`. +//! dimPercent … Lb` (ten operands in the legacy format, without the one before the colour +//! index), `(name) Ln`, its art, `LB`. //! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted //! with `W` clips); a group's transparency and name follow its end. //! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case //! closes), `(op) *` for a guide. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`. //! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`. //! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id. -//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`. +//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`, else +//! the art size around the template box's centre, else (a file in the legacy format another +//! app wrote) the bounding box. //! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …` //! and the samples after `XI`. //! - Art with an appearance the format can't write as plain art (several fills or strokes, @@ -304,6 +307,8 @@ const IGNORED: &[&str] = &[ "Xm", "XP", "Np", + // Flatness (`i`): how finely curves print. + "i", "TE", "TZ", "Xs", @@ -1098,7 +1103,8 @@ impl<'a> Reader<'a> { } } - /// `… Lb`: a layer's options. + /// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place + /// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`). fn layer_attrs(&mut self, vals: &[V]) { let n = Self::nums(vals); let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return }; @@ -1109,12 +1115,13 @@ impl<'a> Reader<'a> { a.printable = flag(3, true); let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0); a.dim = flag(4, false).then_some(dim_pct.round() as u8); - let index = n.get(7).copied().unwrap_or(0.0); - let rgb = |i: usize| n.get(i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); + let at = if n.len() <= 10 { 6 } else { 7 }; + let index = n.get(at).copied().unwrap_or(0.0); + let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 { LayerColor::Preset(index as u8) } else { - LayerColor::Custom([rgb(8), rgb(9), rgb(10)]) + LayerColor::Custom([rgb(1), rgb(2), rgb(3)]) }); } @@ -1438,12 +1445,24 @@ impl<'a> Reader<'a> { (sane(r) && w > 0.0 && h > 0.0).then_some(r) } + /// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`]. + fn first_board(&self) -> Option { + self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()) + } + + /// The page of a file that says nothing of its artboard (the legacy format as other apps write + /// it): its art's bounding box. + fn bbox_board(&self) -> Option { + let [x0, y0, x1, y1] = self.hires.or(self.bbox)?; + Some(Rect::new(x0, y0, x1, y1)).filter(|r| r.width() > 0.0 && r.height() > 0.0) + } + /// Art space → the document (fixed when the art starts, from the artboards read by then). fn ensure_space(&mut self) -> Affine { if let Some(m) = self.to_doc { return m; } - let first = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()); + let first = self.first_board().or_else(|| self.bbox_board()); let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1)); let m = Affine::new([1.0, 0.0, 0.0, -1.0, -top_left.x, top_left.y]); self.to_doc = Some(m); @@ -1475,11 +1494,11 @@ impl<'a> Reader<'a> { return Err("it has no layers".into()); } let to_doc = self.ensure_space(); - let artboard = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()); + let artboard = self.first_board(); drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); let boards: Vec<(String, Rect)> = if self.artboards.is_empty() { - self.cropmarks.or_else(|| self.sized_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() + artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() } else { self.artboards.clone() }; diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import/mod.rs index 8dac8a78f..02906f668 100644 --- a/crates/eps/src/import/mod.rs +++ b/crates/eps/src/import/mod.rs @@ -16,14 +16,18 @@ //! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in //! as groups. The format's published specification documents its header comments (`%AI…`, //! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0 -//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. +//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. A file in that +//! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads +//! an Illustrator EPS's editing copy (#1027). //! //! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page //! of a PostScript file without one. A file whose program can't be read (an operator the -//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in as its -//! preview (a TIFF image, a Windows metafile or an EPSI bitmap), with a warning that names the -//! error, the operator and the procedures it ran in; without a preview, what was drawn before the -//! error is kept (with that warning), else the file is refused. +//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its +//! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not +//! included, as other apps write it), else as its preview (a TIFF image, a Windows metafile or an +//! EPSI bitmap), with a warning that names the error, the operator and the procedures it ran in; +//! without a preview, what was drawn before the error is kept (with that warning), else the file +//! is refused. mod ai; mod ate; @@ -201,6 +205,13 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), }; + // A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the + // prolog defining its operators without including it): its layers, which the program has. + let why = match editing_data.then(|| native::program_alone(ps)).flatten() { + Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }), + Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"), + None => why, + }; if let Some(p) = preview::preview(bytes, ps, frame) { let warnings = vec![format!("this file's PostScript couldn't be read ({why}): {}", p.what)]; return Ok(Imported { document: p.document, warnings, preview: true }); @@ -214,7 +225,7 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result out.warn("the file draws nothing"); Ok(finish(out)) } - Err(e) => Err(format!("this PostScript file can't be read ({}): save it as PDF or SVG in the app that made it", reason(&e, fault.as_ref()))), + Err(_) => Err(format!("this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it")), } } diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index d0aff0c0e..36b082fee 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -1,11 +1,15 @@ //! The layers of an Illustrator EPS or `.ai`. //! //! Besides the page it prints, such a file carries the app's own copy of the art: an EPS after its -//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). It has what the -//! page doesn't: the layers with their names and options, the groups, compound paths and clipping -//! groups, the objects' names, the hidden objects and layers, the art outside the artboards and -//! every artboard. `ai` reads that structure into a document, with the colours, gradients, -//! transparency and images of the art. +//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). A file in the +//! legacy format (a `.ai` in PostScript form: versions 3 to 8, and the apps that still write it) +//! is that copy itself: its program has the layers, and the prolog it names defines the operators +//! for printing (other apps name it without including it, so their files can't be run). +//! +//! The copy has what the page doesn't: the layers with their names and options, the groups, +//! compound paths and clipping groups, the objects' names, the hidden objects and layers, the art +//! outside the artboards and every artboard. `ai` reads that structure into a document, with the +//! colours, gradients, transparency and images of the art. //! //! Text objects are slots in that document: their characters, fonts and places are in the file's //! text document (see `ate`). Type that shows comes from the page, which draws it, into the slot @@ -21,6 +25,7 @@ //! //! Where a file doesn't match what this module expects, it is left alone and its page is imported. +use std::borrow::Cow; use std::collections::BTreeSet; use std::sync::Arc; @@ -991,12 +996,13 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result })) } -/// The EPS `visible` (read as its page) read through its editing copy instead: its layers (the +/// The EPS `visible` (read as its page) read through its editing copy instead (its private data, or +/// its program in the legacy format, see [`editing_copy`]): its layers (the /// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it /// has one that can't be used, `visible` with a warning that says why. pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported { - let Some(data) = ai::eps_data(ps) else { return visible }; + let Some(data) = editing_copy(ps) else { return visible }; let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false)); match result { Ok((document, mut warnings)) => { @@ -1023,7 +1029,7 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported { /// its PDF part). pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outlined: bool) -> (Document, Vec) { let Ok(data) = ai::decode_private(private) else { return (visible, warnings) }; - if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") { + if !has_layers(&data) { return (visible, warnings); } match build(&data, Some(&visible), Page::Artboard, outlined) { @@ -1047,6 +1053,27 @@ pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { build(&data, None, Page::Artboard, false) } +/// Does the editing data (or a program in the legacy format) have layers? +fn has_layers(data: &[u8]) -> bool { + data.windows(15).any(|w| w == b"%AI5_BeginLayer") +} + +/// The editing copy of the art in the PostScript `ps`: an EPS's private data, else the program +/// itself when it has layers (the legacy format). `None` without one. +fn editing_copy(ps: &[u8]) -> Option, String>> { + match ai::eps_data(ps) { + Some(data) => Some(data.map(Cow::Owned)), + None => has_layers(ps).then_some(Ok(Cow::Borrowed(ps))), + } +} + +/// A file in the legacy format whose page can't be read (a `.ai` in PostScript form that names its +/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the +/// document and the import's notes. `None` when the layers aren't in the program. +pub(super) fn program_alone(ps: &[u8]) -> Option), String>> { + (!ai::has_eps_data(ps) && has_layers(ps)).then(|| build(ps, None, Page::Art, false)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/eps/src/import/tests_illustrator.rs b/crates/eps/src/import/tests_illustrator.rs index a3124af1c..849917a75 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use vectorcraft_doc::clipnest::MAX_NEST; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind}; +use vectorcraft_geom::Rect; use crate::import::import; @@ -80,6 +81,80 @@ fn other_postscript_keeps_its_own_u() { assert_eq!(read("%%Creator: some app\n", "", &body).0, "p p"); } +/// A `.ai` in PostScript form as other apps write it (#1027): its header and prolog name the +/// procsets that define its operators (by the DSC resource comments) without including them, so +/// its program can't be run. Its layers come from the program itself: names as they are, the +/// legacy format's ten-operand `Lb`, flatness `i`, the bounding box as the page. +const NAMED_PROLOG: &str = "%!PS-Adobe-3.0\n%%Creator: a CAD app\n%%BoundingBox: 0 0 200 100\n\ + %%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%%+ procset Adobe_IllustratorA_AI3 1.0 0\n\ + %AI3_TemplateBox: 288 384 288 384\n%%EndComments\n%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\n\ + Adobe_packedarray /initialize get exec\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n\ + %%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(SECTION::Cut) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M []0 d\n0 D\n\ + 10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\nLB\n%AI5_EndLayer--\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(SECTION::Hatch) Ln\n0 A\n0 R\n0 0 0 1 K\n0.1 w\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\n\ + %%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\nAdobe_IllustratorA_AI3 /terminate get exec\n%%EOF\n"; + +#[test] +fn a_postscript_ai_that_names_its_prolog_without_including_it_opens_with_its_layers() { + let r = import(NAMED_PROLOG.as_bytes()).unwrap(); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let d = &r.document; + let names: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["SECTION::Cut", "SECTION::Hatch"]); + let colors: Vec<_> = d.layers.iter().map(|l| if let NodeKind::Layer { color, .. } = l.kind { Some(color) } else { None }).collect(); + assert_eq!(colors, [Some(LayerColor::Custom([0, 0, 0])), Some(LayerColor::Custom([191, 63, 255]))]); + assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "the page is the bounding box"); + let square = &d.layers[0].children().unwrap()[0]; + assert_eq!(square.geometric_bounds(), Some(Rect::new(10.0, 10.0, 90.0, 90.0))); + let stroke = square.appearance.stroke().unwrap(); + assert_eq!((stroke.width, stroke.cap), (0.8, LineCap::Round)); + assert_eq!(tree(d.layers[1].children().unwrap()), "p"); +} + +/// A `.ai` in PostScript form whose layers can't be read either says why, after what stopped its +/// program. +#[test] +fn a_postscript_ai_whose_layers_cant_be_read_says_why() { + let damaged = NAMED_PROLOG.replace("0 D\n", "0 D\n1 2 frobnicate\n"); + let e = import(damaged.as_bytes()).unwrap_err(); + assert!(e.contains("`Adobe_packedarray`") && e.contains("its layers couldn't be read either") && e.contains("`frobnicate`"), "{e}"); +} + +/// The editing text of a newer file saved as PostScript (no prolog at all, a non-printing setup): +/// it opens with its sublayers. +#[test] +fn newer_editing_text_as_postscript_opens_with_its_sublayers() { + let layer = + |name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 -1 79 128 255 0 50 0 Lb\n({name}) Ln\n0 A\n0 Xw\n{art}LB\n%AI5_EndLayer--\n"); + let ground = layer("Ground", &(layer("Lawn", "0 0 0 0.3 k\n10 20 m\n70 20 L\n70 80 L\n10 80 L\nf\n") + &layer("Paving", ""))); + let text = format!( + "%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 250 100\n%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 250 100\n%%EndComments\n\ + %%BeginProlog\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n{ground}%%PageTrailer\n%%Trailer\n%%EOF\n" + ); + let r = import(text.as_bytes()).unwrap(); + let d = &r.document; + assert_eq!(d.layers.len(), 1, "{:?}", r.warnings); + let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(subs, ["Lawn", "Paving"]); +} + +/// A file in the legacy format whose prolog is included (here one of our own) runs as its page, and +/// comes in through the layers its program has when they look like the page. +#[test] +fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() { + let prolog = "/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def"; + let body = format!("%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Back) Ln\n0 A\n0 0 0 1 k\n{} LB\n%AI5_EndLayer--\n", tri(10)); + let file = String::from_utf8(ai(AI8, "", &body)).unwrap().replace("%%EndProlog", &format!("{prolog}\n%%EndProlog")); + let r = import(file.as_bytes()).unwrap(); + let names: Vec<_> = r.document.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["Back"], "{:?}", r.warnings); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + // Without the editing data, it is its page. + let page = crate::import::import_with(file.as_bytes(), false).unwrap(); + assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1")); +} + #[test] fn unbalanced_and_runaway_groups_are_read_safely() { // Ends without a beginning end nothing; groups left open hold the rest. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ba350b177..d4e5e6f6e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3686,7 +3686,7 @@ theme Appearance Erscheinungsbild @msg the {tool} tool has no options das Werkzeug „{tool}“ hat keine Optionen @msg there are no objects to release es gibt keine Objekte zum Zurückwandeln @msg there are no printer settings to open here hier lassen sich keine Druckereinstellungen öffnen -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it diese PostScript-Datei kann nicht gelesen werden ({_1}): Speichern Sie sie in der App, mit der sie erstellt wurde, als PDF oder SVG +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it diese PostScript-Datei kann nicht gelesen werden ({why}): Speichern Sie sie in der App, mit der sie erstellt wurde, als PDF oder SVG @msg this file's PostScript couldn't be read ({why}): {what} das PostScript dieser Datei konnte nicht gelesen werden ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens dieses Format fasst eine Zeichenfläche: Exportieren Sie mehrere als PDF oder mit document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that dies ist eine DWG-Zeichnung, die nicht gelesen werden kann: Speichern Sie sie in der CAD-App als ASCII-DXF und öffnen Sie diese Datei diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 07b19b0c8..6bb2aa3f7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3786,7 +3786,7 @@ axis Both Ambos @msg the {tool} tool has no options la herramienta {tool} no tiene opciones @msg there are no objects to release no hay objetos que soltar @msg there are no printer settings to open here aquí no hay ajustes de impresora que abrir -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it no se puede leer este archivo PostScript ({_1}): guárdalo como PDF o SVG en la aplicación que lo creó +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it no se puede leer este archivo PostScript ({why}): guárdalo como PDF o SVG en la aplicación que lo creó @msg this file's PostScript couldn't be read ({why}): {what} no se pudo leer el PostScript de este archivo ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens este formato admite una sola mesa de trabajo: exporta varias como PDF o con document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that este es un dibujo DWG, que no se puede leer: guárdalo desde la aplicación de CAD como DXF ASCII y abre ese archivo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 72b974073..23092e6d3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3783,7 +3783,7 @@ axis Both Les deux @msg the {tool} tool has no options l’outil {tool} n’a pas d’options @msg there are no objects to release il n’y a aucun objet à libérer @msg there are no printer settings to open here il n’y a pas de réglages d’imprimante à ouvrir ici -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it impossible de lire ce fichier PostScript ({_1}) : enregistrez-le au format PDF ou SVG dans l’application qui l’a créé +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it impossible de lire ce fichier PostScript ({why}) : enregistrez-le au format PDF ou SVG dans l’application qui l’a créé @msg this file's PostScript couldn't be read ({why}): {what} impossible de lire le PostScript de ce fichier ({why}) : {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens ce format ne contient qu’un plan de travail : exportez-en plusieurs au format PDF ou avec document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that c’est un dessin DWG, qui ne peut pas être lu : enregistrez-le depuis l’application de CAO au format DXF ASCII et ouvrez ce fichier diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 695b75b45..c76bd87db 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3786,7 +3786,7 @@ axis Both Entrambi @msg the {tool} tool has no options lo strumento {tool} non ha opzioni @msg there are no objects to release non ci sono oggetti da rilasciare @msg there are no printer settings to open here non ci sono impostazioni della stampante da aprire qui -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it impossibile leggere questo file PostScript ({_1}): salvalo come PDF o SVG nell'app che lo ha creato +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it impossibile leggere questo file PostScript ({why}): salvalo come PDF o SVG nell'app che lo ha creato @msg this file's PostScript couldn't be read ({why}): {what} impossibile leggere il PostScript di questo file ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens questo formato contiene una sola tavola da disegno: esportane più di una come PDF o con document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that questo è un disegno DWG, che non può essere letto: salvalo dall'app CAD come DXF ASCII e apri quel file diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b32c26d9a..5835f335d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3636,7 +3636,7 @@ theme Appearance 外観 @msg shadings of an unknown type were left out 種類が不明なシェーディングは除外されました @msg shadings whose functions are of an unknown type are filled with their middle colour 関数の種類が不明なシェーディングは中間の色で塗りつぶされています @msg the file's preview header points outside the file ファイルのプレビューヘッダーがファイルの範囲外を指しています -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it この PostScript ファイルは読み込めません ({_1}): 作成したアプリで PDF または SVG として保存してください +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it この PostScript ファイルは読み込めません ({why}): 作成したアプリで PDF または SVG として保存してください @msg this is not a PostScript file PostScript ファイルではありません @msg a PostScript error ({name} in `{op}`) PostScript エラー (`{op}` での {name}) @msg a PostScript error ({name} in `{op}`), in {procs} {procs} 内の PostScript エラー (`{op}` での {name}) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index be704caea..e324fd011 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3782,7 +3782,7 @@ axis Both Оба @msg the {tool} tool has no options инструмент {tool} не имеет параметров @msg there are no objects to release нет объектов для освобождения @msg there are no printer settings to open here здесь нет настроек принтера для открытия -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it этот файл PostScript невозможно прочитать ({_1}): сохраните его как PDF или SVG в приложении, которое его создало +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it этот файл PostScript невозможно прочитать ({why}): сохраните его как PDF или SVG в приложении, которое его создало @msg this file's PostScript couldn't be read ({why}): {what} PostScript этого файла невозможно прочитать ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens этот формат содержит одну монтажную область: экспортируйте несколько как PDF или с помощью document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that это чертёж DWG, который невозможно прочитать: сохраните его из приложения CAD как ASCII DXF и откройте его diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6ce17e591..c8410593f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3769,7 +3769,7 @@ axis Both Обидві @msg the {tool} tool has no options інструмент {tool} не має параметрів @msg there are no objects to release немає об’єктів для звільнення @msg there are no printer settings to open here тут немає налаштувань принтера для відкриття -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it цей файл PostScript неможливо прочитати ({_1}): збережіть його як PDF або SVG у додатку, який його створив +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it цей файл PostScript неможливо прочитати ({why}): збережіть його як PDF або SVG у додатку, який його створив @msg this file's PostScript couldn't be read ({why}): {what} PostScript цього файлу неможливо прочитати ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens цей формат містить одну монтажну область: експортуйте кілька як PDF або за допомогою document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that це креслення DWG, яке неможливо прочитати: збережіть його з CAD як ASCII DXF і відкрийте його diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d9019c75e..6830f7fe5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -29,14 +29,16 @@ private editing data. VectorCraft: - **Reads** the PDF page (vectors, text, images, colour, transparency, layers via optional content) and the *structure* of the editing data (below). Opens Illustrator EPS the same way through its own PostScript - Level 3 interpreter. + Level 3 interpreter. A `.ai` in PostScript form (versions 3 to 8, and what Rhino and other CAD apps + write) is its own editing data: it opens through the layers its program has, compared with its page + when the program runs, from the layers alone when it names its prolog without including it (#1027). - **Writes** a PDF-compatible `.ai` (Save As › `ai`): Illustrator opens it as plain art. It never writes Illustrator's private editing data, which is undocumented. - **Known failures in users' files:** layer structure flattened or multiplied (#951: 109 layers instead of 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects, - brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025), legacy `.ai` - without the prolog not read. + brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai` + without the prolog opens since #1027, its legacy type (`To` … `TO`) not read yet. - Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible only). Closing the open-side issues: 10–20 h with users' files. @@ -78,5 +80,6 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | `.ai` in PostScript form opens through its layers, also without its prolog (#1027) | | 2026-10-11 | minor | `.ai` editing data: global process colours and pattern fills read, so they no longer drop sublayers (#1025) | | 2026-10-10 | major | First checklist; moved the `.ai` editing-data scope from the ROADMAP's "Out of scope" here; added the user-reported `.ai` failures | diff --git a/docs/gaps.md b/docs/gaps.md index 8a81e01f7..f27098dc0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -55,8 +55,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead of 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction - lines (#779), open speed on large files (#758), legacy `.ai` without the prolog, live effects, brushes and - symbols as live objects (#637; pattern fills and global colours are read since #1025). Writing Illustrator's own editing data is out of scope + lines (#779), open speed on large files (#758), legacy type in `.ai` files in PostScript form (those without + their prolog open since #1027), live effects, brushes and symbols as live objects (#637; pattern fills and global colours are read since #1025). Writing Illustrator's own editing data is out of scope (undocumented); Save As `.ai` stays PDF-compatible. - **Evidence:** the issues above; 28 of the 350 issues are about `.ai`/EPS (about 22 people, the most common single format theme), 20 fixed, and the 8 open ones are about structure (layers, guides, speed, live objects), not missing @@ -200,6 +200,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | G9: legacy `.ai` without the prolog opens with its layers (#1027) | | 2026-10-11 | minor | G9: `.ai` pattern fills and global colours read from the editing data (#1025) | | 2026-10-10 | minor | G3: initial Revolve landed (#846), 45–75 h left | | 2026-10-10 | minor | G9 evidence counted from the tracker (28 `.ai`/EPS issues, 20 fixed); impact narrowed to handing files back | From 10f1c56a861869936af0027cef14b1cf5d8cf515 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 19:47:38 -0400 Subject: [PATCH 73/97] Release prep: 0.9.0 and a Linux riscv64 release build (#1071) - Version 0.8.0 -> 0.9.0 (cargo xtask version set; Cargo.lock refreshed). - release.yml: new linux-riscv64 job, ported from GridCraft. Cross-compiles riscv64gc-unknown-linux-gnu on ubuntu-24.04 against riscv64 -dev packages from ports.ubuntu.com (the same set the linux job installs), packages a tar.gz, and runs vectorcraft-cli --version under qemu-riscv64. The draft release job now needs it, so its tarball is uploaded and in SHA256SUMS. - packaging/linux/package.sh: CROSS_ARCH / CROSS_TARGET / CROSS_COMPILE / EMULATOR support, as in GridCraft. - docs/releasing.md: list the riscv64 artifact. Checked locally: cargo check -p vectorcraft -p vectorcraft-cli --target riscv64gc-unknown-linux-gnu, cargo fmt --check, actionlint, shellcheck. Co-authored-by: Claude Opus 5.5 --- .github/workflows/release.yml | 82 ++++++++++++++++++++++++++++++++++- Cargo.lock | 54 +++++++++++------------ Cargo.toml | 2 +- docs/releasing.md | 8 +++- packaging/linux/package.sh | 27 +++++++++--- 5 files changed, 136 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 706daac6d..c7b032f07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -377,11 +377,91 @@ jobs: path: dist/release/* if-no-files-found: error + linux-riscv64: + name: Linux riscv64 + needs: version + # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release + # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's + # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the + # CLI is smoke-tested under QEMU (package.sh runs `vectorcraft-cli --version` through $EMULATOR). + runs-on: ubuntu-24.04 + # No `environment: release`: this job signs nothing, so it gets no release secrets. + env: + VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }} + VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v7 + # Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build + # step). Bump the commit deliberately; see craftrules standards/fonts.md. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - uses: dtolnay/rust-toolchain@stable + with: + targets: riscv64gc-unknown-linux-gnu + - uses: Swatinem/rust-cache@v2 + with: + key: release-linux-riscv64 + - name: Apply version override + if: inputs.version != '' + run: cargo xtask version set "$VECTORCRAFT_VERSION" + - name: Install cross-compilation dependencies + run: | + . /etc/os-release + sudo dpkg --add-architecture riscv64 + # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + fi + if [ -f /etc/apt/sources.list ]; then + sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list + sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list + fi + for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do + echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" + done | sudo tee /etc/apt/sources.list.d/riscv64.list + sudo apt-get update + # The riscv64 variants of the linux job's -dev packages. + sudo apt-get install -y --no-install-recommends \ + gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ + libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ + libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \ + desktop-file-utils appstream + - name: Build and package (tar.gz) + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc + CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc + CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++ + AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar + PKG_CONFIG_SYSROOT_DIR: / + PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig + PKG_CONFIG_ALLOW_CROSS: "1" + CROSS_ARCH: riscv64 + CROSS_TARGET: riscv64gc-unknown-linux-gnu + CROSS_COMPILE: riscv64-linux-gnu- + EMULATOR: qemu-riscv64 + QEMU_LD_PREFIX: /usr/riscv64-linux-gnu + run: packaging/linux/package.sh --formats tar + - name: Check the package + run: | + tar -tzf dist/release/vectorcraft-*-linux-riscv64.tar.gz \ + | grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$' + file target/riscv64gc-unknown-linux-gnu/release/vectorcraft | grep -F 'RISC-V' + - uses: actions/upload-artifact@v7 + with: + name: release-linux-riscv64 + path: dist/release/*.tar.gz + if-no-files-found: error + release: name: Draft GitHub Release # Needs the signed jobs, so a branch dry run (where the environment refuses them) never # drafts a release. - needs: [version, macos, windows, linux, flatpak, freebsd, web] + needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] runs-on: ubuntu-latest environment: release permissions: diff --git a/Cargo.lock b/Cargo.lock index ce87c92c9..9b09db608 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4320,7 +4320,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.8.0" +version = "0.9.0" dependencies = [ "arboard", "block2 0.6.2", @@ -4350,7 +4350,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.8.0" +version = "0.9.0" dependencies = [ "crc32fast", "flate2", @@ -4361,7 +4361,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.8.0" +version = "0.9.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -4373,7 +4373,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4389,7 +4389,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.8.0" +version = "0.9.0" dependencies = [ "log", "objc2-core-foundation", @@ -4410,7 +4410,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.8.0" +version = "0.9.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -4422,7 +4422,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "jpeg-encoder", @@ -4439,7 +4439,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.8.0" +version = "0.9.0" dependencies = [ "kurbo 0.13.1", "serde_json", @@ -4454,7 +4454,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.8.0" +version = "0.9.0" dependencies = [ "crc32fast", "flate2", @@ -4489,7 +4489,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flate2", "image", @@ -4509,7 +4509,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flate2", "proptest", @@ -4523,7 +4523,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.8.0" +version = "0.9.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -4535,7 +4535,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "log", @@ -4554,7 +4554,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4568,7 +4568,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.8.0" +version = "0.9.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -4580,7 +4580,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.8.0" +version = "0.9.0" dependencies = [ "aes", "flate2", @@ -4609,7 +4609,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.8.0" +version = "0.9.0" dependencies = [ "serde", "serde_json", @@ -4623,7 +4623,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flate2", "gif", @@ -4648,7 +4648,7 @@ dependencies = [ [[package]] name = "vectorcraft-svg" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flate2", "image", @@ -4672,7 +4672,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4689,7 +4689,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.8.0" +version = "0.9.0" dependencies = [ "harfrust 0.13.3", "kurbo 0.13.1", @@ -4707,14 +4707,14 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.8.0" +version = "0.9.0" dependencies = [ "vectorcraft-geom", ] [[package]] name = "vectorcraft-tools" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4729,7 +4729,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4742,7 +4742,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.8.0" +version = "0.9.0" dependencies = [ "egui", "egui_extras", @@ -4773,7 +4773,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.8.0" +version = "0.9.0" dependencies = [ "eframe", "egui", @@ -5883,7 +5883,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.8.0" +version = "0.9.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index ae4d113a8..dfbb344c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"] [workspace.package] -version = "0.8.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/docs/releasing.md b/docs/releasing.md index fe475dd26..bfa8782c8 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,7 +1,7 @@ # Releasing VectorCraft Every push to the `release` branch runs `.github/workflows/release.yml`. The workflow builds -installers for macOS, Windows, Linux (including Flatpak bundles) and FreeBSD, plus the web build, and creates or updates a +installers for macOS, Windows, Linux (including Flatpak bundles and a RISC-V tarball) and FreeBSD, plus the web build, and creates or updates a **draft** GitHub Release named `VectorCraft v`. Nobody sees a draft until a maintainer publishes it. @@ -50,6 +50,7 @@ and no draft release is created. | Windows on ARM64 | `vectorcraft--windows-arm64.msi`, `vectorcraft--windows-arm64-portable.zip` | `windows-latest` (cross-compiled) | | Linux x86_64 | `vectorcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `vectorcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| Linux riscv64 | `vectorcraft--linux-riscv64.tar.gz` (cross-compiled, glibc >= 2.39; CLI smoke-tested under QEMU) | `ubuntu-24.04` | | Linux AppImage updates | `vectorcraft--linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImages | | Flatpak x86_64, aarch64 | `vectorcraft--linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) | | FreeBSD 14 x86_64 | `vectorcraft--freebsd-x86_64.tar.gz` | a FreeBSD 14.3 VM on `ubuntu-latest` | @@ -144,6 +145,11 @@ glibc 2.35 or newer: Ubuntu 22.04+, Debian 12+, Fedora 36+ and RHEL 10. Moving t image raises that floor, so do it deliberately. After packaging, the job prints the `.deb`'s metadata and contents, runs `ldd` on the binary and runs each AppImage with `--version`. +**RISC-V.** The `linux-riscv64` job cross-compiles `riscv64gc-unknown-linux-gnu` on `ubuntu-24.04` +against riscv64 libraries from ports.ubuntu.com (so the floor there is glibc 2.39) and runs +`package.sh --formats tar` with `CROSS_ARCH`, `CROSS_TARGET`, `CROSS_COMPILE` and +`EMULATOR=qemu-riscv64` set: a `.tar.gz` only, with `vectorcraft-cli --version` run under QEMU. + **AppImage updates.** Each AppImage embeds the update information `gh-releases-zsync|storytold|vectorcraft|latest|vectorcraft-*-linux-.AppImage.zsync` (appimagetool `-u`), and `package.sh` writes the matching `.zsync` beside it when `zsyncmake` (the diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 1036031f2..7efbaf44e 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and package VectorCraft for Linux ( is x86_64 or aarch64): +# Build and package VectorCraft for Linux ( is x86_64 or aarch64, or riscv64 when cross-compiling): # # $DIST/vectorcraft--linux-.AppImage any distro with glibc >= the build host's # $DIST/vectorcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) @@ -9,6 +9,10 @@ # # Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] # +# Cross-compiling (release.yml's linux-riscv64 job): set CROSS_ARCH (riscv64), CROSS_TARGET (the Rust +# target), CROSS_COMPILE (the binutils prefix, for strip) and EMULATOR (e.g. qemu-riscv64, to +# smoke-test the CLI), and pass --formats tar. +# # Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, @@ -25,13 +29,14 @@ while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + -h | --help) sed -n '2,19p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done -ARCH="$(uname -m)" +ARCH="${CROSS_ARCH:-$(uname -m)}" case "$ARCH" in + riscv64) DEB_ARCH=riscv64 ;; x86_64) DEB_ARCH=amd64 ;; aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; @@ -42,9 +47,13 @@ BASENAME="vectorcraft-$VERSION-linux-$ARCH" echo "==> VectorCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then - (cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli) + if [ -n "${CROSS_TARGET:-}" ]; then + (cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli --target "$CROSS_TARGET") + else + (cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli) + fi fi -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" WORK="$CARGO_TARGET_DIR/linux-package" STAGE="$WORK/root" rm -rf "$WORK" @@ -52,7 +61,7 @@ rm -rf "$WORK" # ---- stage an FHS tree (shared by every format) ------------------------------------------------- install -Dm755 "$BIN/vectorcraft" "$STAGE/usr/bin/vectorcraft" install -Dm755 "$BIN/vectorcraft-cli" "$STAGE/usr/bin/vectorcraft-cli" -strip "$STAGE/usr/bin/vectorcraft" "$STAGE/usr/bin/vectorcraft-cli" 2>/dev/null || true +"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/vectorcraft" "$STAGE/usr/bin/vectorcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" mkdir -p "$STAGE/usr/share/metainfo" @@ -135,6 +144,10 @@ if has appimage; then fi fi -"$STAGE/usr/bin/vectorcraft-cli" --version +if [ -z "${CROSS_TARGET:-}" ]; then + "$STAGE/usr/bin/vectorcraft-cli" --version +elif [ -n "${EMULATOR:-}" ]; then + "$EMULATOR" "$STAGE/usr/bin/vectorcraft-cli" --version +fi echo "==> done" ls -lh "$DIST" From 75c587c8a0d21de8e2a07706752fa2ec3e01542b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 06:16:20 +0700 Subject: [PATCH 74/97] #1063: a fill chosen with characters selected colours just them Choosing a fill or stroke (Color panel, Swatches, the proxies) while the Type tool had a few characters selected painted every run of the type object. As in Illustrator, it now goes to the selected characters only: the runs are split at the selection's ends and the paint is placed on the runs in between, as one undo step; a stroke gets a 1 pt weight where it had none. With only a caret, or with explicit ids or an item, paint.setFill/setStroke behave as before. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/paint.rs | 30 ++++++++++++++++++++++++++- crates/engine/src/cmd/textedit.rs | 2 +- crates/engine/src/tests_textedit.rs | 32 +++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f404f12a9..0a638640c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/paint.rs b/crates/engine/src/cmd/paint.rs index 08f0cb8d3..236112249 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only", has_doc, |s, p| set_paint(s, p, true) ), @@ -375,6 +375,34 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result { /// remember the paint. fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result { let cmd = if fill { "paint.setFill" } else { "paint.setStroke" }; + // Characters selected with the Type tool take the paint, not the whole type object (#1063). + if p.get("ids").is_none() + && p.get("item").is_none() + && let Some((id, a, b)) = super::textedit::editing_range(s)? + && a != b + { + s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; + let (xf, lb) = (t.xf, t.local_bounds()); + vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| { + if fill { + st.fill = place_run_paint(&paint, p, xf, lb); + } else { + if !paint.is_none() { + run_stroke_weight(st); + } + st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width)); + } + }); + super::typecmd::refresh_bounds(t); + Ok(()) + })?; + if bool_or(p, "focus", true) { + s.fill_active = fill; + } + s.remember_paint(&paint); + return ok(); + } // Resolve the requested targets before mutating defaults or the active proxy. let item = item_target(s, p, cmd)?.of_kind(s, fill); let selected = item.targets(s, p)?; diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 7c0b41c7e..7a871754c 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -542,7 +542,7 @@ enum Step { } /// The Type tool's selected text while it edits (its typing session ends first: one undo step). -fn editing_range(s: &mut Session) -> Result> { +pub(crate) fn editing_range(s: &mut Session) -> Result> { let o = s.tool_options(); let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return Ok(None) }; if super::edit::typing_in_progress(s) { diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index 4de6cdbc3..cbaa5443a 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -563,3 +563,35 @@ fn automatic_alignment_updates_during_edits_and_can_be_overridden() { legacy["para"]["justify"] = json!("Left"); assert_eq!(serde_json::from_value::(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left); } + +/// #1063: a fill (or stroke) chosen while the Type tool has characters selected colours just +/// those characters, as one undo step; the rest of the type keeps its colour. +#[test] +fn a_fill_chosen_with_characters_selected_colours_only_them() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 50.0, 300.0); + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("Sample Text", v).unwrap(); + let before = obj(&s, id).runs[0].style.fill.clone(); + s.set_tool_option("select", &json!({"start": 0, "end": 6})); + s.execute("paint.setFill", &json!({"color": "#00a000"})).unwrap(); + let t = obj(&s, id); + assert_eq!(t.runs.len(), 2); + assert_eq!(t.runs[0].text, "Sample"); + assert_eq!(t.runs[0].style.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 160, 0))); + assert_eq!(t.runs[1].text, " Text"); + assert_eq!(t.runs[1].style.fill, before); + // A stroke goes on the selected characters only, with a weight to show. + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + let t = obj(&s, id); + assert!(t.runs[0].style.stroke_width > 0.0 && !t.runs[0].style.stroke.is_none()); + assert!(t.runs[1].style.stroke.is_none()); + // One undo step each. + let labels = undo_labels(&s); + assert_eq!(&labels[labels.len() - 2..], ["Fill Color", "Stroke Color"]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(obj(&s, id).runs.iter().all(|r| r.style.fill == before)); +} From c5274beb23fe90408c34fb6930651bfeb6629800 Mon Sep 17 00:00:00 2001 From: lollinoo <99369406+lollinoo@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:21:23 +0700 Subject: [PATCH 75/97] #1034: scaled type shows and takes its scaled sizes After scaling 12 pt type 2x, the Character panel still showed 12 pt and typing 10 pt gave 20 pt type. Character controls and text commands now work in document points: the size, leading and baseline shift shown and set are the stored ones times the text's scale along its em height, and horizontal scale includes the ratio of the transformed em axes. Rotation, reflection and shear stay in the transform, so text remains editable. Groups' text counts, keyboard increments use the same units, Shrink Text to Fit works in document tenths, and a collapsed transform rejects dimensional edits without touching the document. text.getRange takes effective: true for the document-space values. --- ROADMAP.md | 2 +- contributors/people.toml | 3 + crates/doc/src/text.rs | 55 +++++ crates/engine/src/cmd/panelcmds.rs | 21 +- crates/engine/src/cmd/textedit.rs | 59 +++-- crates/engine/src/cmd/typecmd.rs | 22 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_typescale.rs | 229 ++++++++++++++++++++ crates/text/src/layout.rs | 12 +- crates/text/src/tests_fit.rs | 13 ++ crates/ui-egui/src/panels/character.rs | 143 ++++++++++-- crates/ui-egui/src/panels/paragraph.rs | 10 +- crates/ui-egui/src/panels/tests_fontsize.rs | 32 +++ docs/gaps.md | 8 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 9 +- docs/type-parity.md | 5 +- 17 files changed, 568 insertions(+), 62 deletions(-) create mode 100644 crates/engine/src/tests_typescale.rs diff --git a/ROADMAP.md b/ROADMAP.md index 0a638640c..42ffe80b0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/contributors/people.toml b/contributors/people.toml index 45cf1695c..c8c0772b3 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,9 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.lollinoo] +sync_github_name = true + [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas" diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index aef35e23c..7ed8247b9 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -954,7 +954,49 @@ pub struct TextObject { pub cached_baselines: Vec<(Point, Point)>, } +/// Conversion from stored character attributes to document-space type controls. +/// Point size follows the transformed em-height axis; horizontal scale is the ratio of +/// the two transformed em axes. Rotation, reflection and shear remain in the affine. +#[derive(Clone, Copy, Debug)] +pub struct TextStyleScale { + pub points: f64, + pub horizontal: f64, +} + impl TextObject { + /// None for collapsed or unrepresentable transforms, whose type dimensions cannot + /// be edited by compensating the stored attributes. + pub fn style_scale(&self) -> Option { + let [a, b, c, d, e, f] = self.xf.as_coeffs(); + if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) { + return None; + } + let (sx, sy) = (a.hypot(b), c.hypot(d)); + let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy); + let horizontal = sx / sy; + if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 { + return None; + } + // Avoid a pure rotation turning otherwise identical fields into mixed values. + let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v }; + Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) }) + } + + /// Character attributes as presented by controls (stored runs stay local for layout + /// and rich-text clipboard operations). Explicit vertical character scale stays separate. + pub fn effective_char_style(&self, style: &CharStyle) -> Option { + let scale = self.style_scale()?; + let mut style = style.clone(); + style.size *= scale.points; + style.leading = style.leading.map(|v| v * scale.points); + style.baseline_shift *= scale.points; + style.h_scale *= scale.horizontal; + if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) { + return None; + } + Some(style) + } + pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { Self { vertical: false, @@ -1190,6 +1232,19 @@ impl TextObject { mod tests { use super::*; + #[test] + fn character_controls_reject_collapsed_and_nonfinite_transforms() { + let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); + for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] { + t.xf = Affine::new(matrix); + assert!(t.effective_char_style(&t.first_style()).is_none()); + } + t.xf = Affine::rotate(0.83); + assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0); + t.xf = Affine::scale(1e308); + assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size"); + } + #[test] fn point_text_basics() { let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default()); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index c8568d9d2..fe1c4d642 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -185,14 +185,7 @@ const MAX_PARA: f64 = 1296.0; fn set_format(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setFormat"; - let ids: Vec = { - let ids = targets(s, p)?; - let d = &s.doc()?.doc; - ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect() - }; - if ids.is_empty() { - return Err(bad(C, "no text objects selected")); - } + let ids = super::typecmd::text_targets(s, p, C)?; let num = |k: &str| p.get(k).and_then(Value::as_f64); let flag = |k: &str| p.get(k).and_then(Value::as_bool); let kerning = match p.get("kerning") { @@ -285,15 +278,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result { s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?; + let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?; super::typecmd::style_chars(t, range, |st| { if let Some(k) = kerning { st.kerning = k; } - if let Some(v) = num("baselineShift") { - st.baseline_shift = v.clamp(-1296.0, 1296.0); + if let Some(v) = baseline_shift { + st.baseline_shift = v; } - if let Some(v) = num("hScale") { - st.h_scale = v.clamp(1.0, 10000.0); + if let Some(v) = h_scale { + st.h_scale = v; } if let Some(v) = num("vScale") { st.v_scale = v.clamp(1.0, 10000.0); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 7a871754c..3a2593ac3 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{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}", + "{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 (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}", has_doc, set_range_style ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Get Text Range", [], None, - "{id, start?, end?} → {text, runs: [{text, style}], length}", + "{id, start?, end?, effective?: bool = false} → {text, runs: [{text, style}], length}; effective uses document-point size/leading/baseline shift and transformed horizontal scale; default returns stored local styles for rich-text copying", has_doc, get_range ), @@ -174,7 +174,12 @@ fn get_range(s: &mut Session, p: &Value) -> Result { let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); let (a, b) = range_of(p, len); - let runs = edit::slice_runs(&t.runs, a, b); + let mut runs = edit::slice_runs(&t.runs, a, b); + if p.get("effective").and_then(Value::as_bool).unwrap_or(false) { + for run in &mut runs { + run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?; + } + } let text: String = runs.iter().map(|r| r.text.as_str()).collect(); Ok(json!({"text": text, "runs": runs, "length": len})) } @@ -316,6 +321,15 @@ impl CharChange { && self.char_align.is_none() } + fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> { + use super::typecmd::local_type_value; + self.size = self.size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, cmd)).transpose()?; + self.leading = self.leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, cmd)).transpose()).transpose()?; + self.baseline_shift = self.baseline_shift.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?; + self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?; + Ok(()) + } + pub(crate) fn apply(&self, st: &mut CharStyle) { if let Some(f) = &self.font { st.font_family = f.clone(); @@ -338,10 +352,10 @@ impl CharChange { st.font_version = None; } if let Some(v) = self.size { - st.size = v.clamp(0.1, 1296.0); + st.size = v; } if let Some(v) = self.leading { - st.leading = v.map(|l| l.clamp(0.1, 5000.0)); + st.leading = v; } if let Some(v) = self.tracking { st.tracking = v.clamp(-1000.0, 10000.0); @@ -350,10 +364,10 @@ impl CharChange { st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0)); } if let Some(v) = self.baseline_shift { - st.baseline_shift = v.clamp(-1296.0, 1296.0); + st.baseline_shift = v; } if let Some(v) = self.h_scale { - st.h_scale = v.clamp(1.0, 10000.0); + st.h_scale = v; } if let Some(v) = self.v_scale { st.v_scale = v.clamp(1.0, 10000.0); @@ -445,12 +459,13 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "missing `id`: give one, or select one type object")), }, }; - let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?; + let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?; if change.is_empty() { return Err(bad(C, "nothing to change")); } let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some()); let t = text_ref(s, id)?; + change.localize(&t, C)?; let (a, b) = range_of(p, edit::runs_len(&t.runs)); let n = s.edit("Character", |d, _| { let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; @@ -605,13 +620,27 @@ fn type_step(s: &mut Session, p: &Value) -> Result { s.edit("Character", |d, _| { for &(id, a, b) in &ranges { let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; - edit::style_range(&mut t.runs, a, b, |st| match step { - Step::Size => st.size = (st.size + inc).clamp(0.1, 1296.0), - Step::Leading => st.leading = Some((st.effective_leading() + inc).clamp(0.1, 5000.0)), - Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0), - Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)), - Step::BaselineShift => st.baseline_shift = (st.baseline_shift + inc).clamp(-1296.0, 1296.0), - }); + let points = if matches!(step, Step::Size | Step::Leading | Step::BaselineShift) { + t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points + } else { + 1.0 + }; + let mut runs = edit::slice_runs(&t.runs, a, b); + for run in &mut runs { + let st = &mut run.style; + match step { + Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?, + Step::Leading => { + st.leading = Some(super::typecmd::local_type_value(t, st.effective_leading() * points + inc, (0.1, 5000.0), false, C)?); + } + Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0), + Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)), + Step::BaselineShift => { + st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?; + } + } + } + edit::replace_range_styled(&mut t.runs, a, b, &runs); refresh_bounds(t); } Ok(()) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 5692b6ad3..f75120a66 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)", + "{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)", has_doc, set_style ), @@ -289,6 +289,22 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result Result { + if !value.is_finite() { + return Err(bad(cmd, "type dimension must be finite")); + } + let scale = t.style_scale().ok_or_else(|| bad(cmd, "text transform is collapsed or unrepresentable"))?; + let local = value.clamp(limits.0, limits.1) / if horizontal { scale.horizontal } else { scale.points }; + // Bound local dimensions too: an extreme inverse scale can produce a finite size + // whose leading, glyph advances or layout bounds overflow before the edit's geometry check. + if !local.is_finite() || local.abs() > crate::MAX_COORD || (value != 0.0 && local == 0.0) { + return Err(bad(cmd, "type dimension cannot be represented with this text transform")); + } + Ok(local) +} + /// Optional `start`/`end` byte offsets of a text command: the character range it styles and the /// paragraphs it touches. Without either, the command applies to all the text. #[derive(Clone, Copy, Debug, PartialEq)] @@ -415,6 +431,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result { s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?; + let leading = leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, C)).transpose()).transpose()?; let before = protect.then(|| t.runs.clone()); style_chars(t, range, |st| { if let Some(f) = &font { @@ -428,7 +446,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { st.font_version = None; } if let Some(v) = size { - st.size = v.clamp(0.1, 1296.0); + st.size = v; } if let Some(l) = leading { st.leading = l; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7de3ed337..7b107a5f5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1686,6 +1686,8 @@ mod tests_transparencygrid; #[cfg(test)] mod tests_typearea; #[cfg(test)] +mod tests_typescale; +#[cfg(test)] mod tests_units; #[cfg(test)] mod tests_variables; diff --git a/crates/engine/src/tests_typescale.rs b/crates/engine/src/tests_typescale.rs new file mode 100644 index 000000000..cfb4e1517 --- /dev/null +++ b/crates/engine/src/tests_typescale.rs @@ -0,0 +1,229 @@ +//! #1034: Character sizes and horizontal scales account for the object's affine transform. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind, TextObject}; +use vectorcraft_geom::Point; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn create(s: &mut Session, size: f64) -> NodeId { + NodeId(s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Axis label", "size": size})).unwrap()["id"].as_u64().unwrap()) +} + +fn text(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +fn em(t: &TextObject, run: usize) -> (f64, f64) { + let st = &t.runs[run].style; + let origin = t.xf * Point::ZERO; + ((t.xf * Point::new(st.size * st.h_scale / 100.0, 0.0) - origin).hypot(), (t.xf * Point::new(0.0, st.size * st.v_scale / 100.0) - origin).hypot()) +} + +fn near(actual: f64, expected: f64) { + assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}"); +} + +#[test] +fn scaled_font_size_setter_sets_document_points_and_undoes() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + s.execute("object.scale", &json!({"sx": 150})).unwrap(); + let before = text(&s, id); + near(em(&before, 0).1, 36.0); + s.execute("text.setStyle", &json!({"size": 10})).unwrap(); + let after = text(&s, id); + near(em(&after, 0).1, 10.0); + assert_eq!(after.xf, before.xf, "font editing preserves placement and rotation"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(text(&s, id), after); +} + +#[test] +fn group_font_size_setter_handles_different_accumulated_scales() { + let mut s = session(); + let a = create(&mut s, 12.0); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + let b = create(&mut s, 18.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + near(em(&text(&s, a), 0).1, 48.0); + near(em(&text(&s, b), 0).1, 36.0); + s.execute("text.setStyle", &json!({"size": 10})).unwrap(); + near(em(&text(&s, a), 0).1, 10.0); + near(em(&text(&s, b), 0).1, 10.0); +} + +#[test] +fn scaled_range_size_and_keyboard_steps_use_document_points() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0]})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "size": 10})).unwrap(); + near(em(&text(&s, id), 0).1, 10.0); + near(em(&text(&s, id), 1).1, 24.0); + s.prefs.type_size_increment = 2.0; + s.execute("type.step", &json!({"attribute": "size", "id": id.0, "start": 0, "end": 4})).unwrap(); + near(em(&text(&s, id), 0).1, 12.0); + near(em(&text(&s, id), 1).1, 24.0); +} + +#[test] +fn horizontal_scale_reset_removes_object_stretch_including_groups() { + for grouped in [false, true] { + let mut s = session(); + let id = create(&mut s, 12.0); + if grouped { + s.execute("object.group", &json!({})).unwrap(); + } + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let before = text(&s, id); + s.execute("text.setFormat", &json!({"hScale": 100, "vScale": 100})).unwrap(); + let after = text(&s, id); + near(em(&after, 0).0, 12.0); + near(em(&after, 0).1, 12.0); + assert_eq!(after.xf, before.xf); + } +} + +#[test] +fn rotated_reflected_nonuniform_range_edits_preserve_the_affine() { + let mut s = session(); + let id = create(&mut s, 12.0); + // A quarter turn, reflected in the baseline axis: horizontal ×2, vertical ×3. + s.execute("object.transform", &json!({"matrix": [0, -2, -3, 0, 0, 0]})).unwrap(); + let before = text(&s, id); + s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100, "vScale": 100})).unwrap(); + let after = text(&s, id); + near(em(&after, 0).0, 10.0); + near(em(&after, 0).1, 10.0); + assert_eq!(after.xf, before.xf); + // The native document representation retains both the compensation and the affine. + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let reopened = vectorcraft_format::load(&bytes).unwrap(); + let NodeKind::Text(t) = &reopened.node(id).unwrap().kind else { panic!("not text") }; + near(em(t, 0).0, 10.0); + near(em(t, 0).1, 10.0); +} + +#[test] +fn effective_range_query_leaves_raw_runs_available_for_clipboard() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap(); + let query = |s: &mut Session, effective| -> Value { + s.execute("text.getRange", &json!({"id": id.0, "effective": effective})).unwrap()["runs"][0]["style"].clone() + }; + let effective = query(&mut s, true); + assert_eq!(effective["size"], 24.0); + assert_eq!(effective["h_scale"], 150.0); + let raw = query(&mut s, false); + assert_eq!(raw["size"], 12.0); + assert_eq!(raw["h_scale"], 100.0); +} + +#[test] +fn scaled_point_units_and_small_sizes_are_converted_before_local_storage() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + s.execute("text.setStyle", &json!({"size": 0.1, "leading": 14})).unwrap(); + let t = text(&s, id); + near(em(&t, 0).1, 0.1); + near(t.runs[0].style.leading.unwrap() * 2.0, 14.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 12, "baselineShift": 4, "leading": 16})).unwrap(); + let t = text(&s, id); + near(t.runs[0].style.baseline_shift * 2.0, 4.0); + near(t.runs[0].style.leading.unwrap() * 2.0, 16.0); +} + +#[test] +fn singular_text_size_edit_returns_an_error_and_rolls_back_the_group() { + let mut s = session(); + let a = create(&mut s, 12.0); + let b = create(&mut s, 18.0); + s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + assert!(s.execute("text.setStyle", &json!({"size": 10})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.execute("text.setRangeStyle", &json!({"id": b.0, "size": 10})).is_err()); + assert!(s.execute("text.setFormat", &json!({"id": b.0, "hScale": 100})).is_err()); + // Non-dimensional edits remain possible, including on imported collapsed text. + s.execute("text.setStyle", &json!({"fill": "#123456"})).unwrap(); +} + +#[test] +fn sheared_text_keeps_its_affine_and_accepts_exact_em_dimensions() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.transform", &json!({"matrix": [2, 0, 1, 2, 0, 0]})).unwrap(); + let before = text(&s, id); + s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100})).unwrap(); + let after = text(&s, id); + near(em(&after, 0).0, 10.0); + near(em(&after, 0).1, 10.0); + assert_eq!(after.xf, before.xf); +} + +#[test] +fn area_frame_resize_reflows_at_same_size_and_object_scaling_reports_scaled_size() { + let mut s = session(); + let id = NodeId( + s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Area label", "size": 12, "area": {"width": 120, "height": 80}})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0], "typeAreas": true})).unwrap(); + near(em(&text(&s, id), 0).1, 12.0); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + near(em(&text(&s, id), 0).1, 24.0); + s.execute("text.setStyle", &json!({"size": 10})).unwrap(); + near(em(&text(&s, id), 0).1, 10.0); +} + +#[test] +fn point_based_keyboard_increments_and_format_fields_follow_document_units() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + s.execute("text.setStyle", &json!({"leading": 14})).unwrap(); + s.execute("text.setFormat", &json!({"baselineShift": 4})).unwrap(); + s.prefs.type_size_increment = 2.0; + s.prefs.baseline_shift_increment = 3.0; + s.execute("type.step", &json!({"attribute": "leading"})).unwrap(); + s.execute("type.step", &json!({"attribute": "baselineShift"})).unwrap(); + let t = text(&s, id); + near(t.runs[0].style.leading.unwrap() * 2.0, 16.0); + near(t.runs[0].style.baseline_shift * 2.0, 7.0); +} + +#[test] +fn tiny_finite_transform_rejects_unsafe_compensation_without_history_changes() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.transform", &json!({"matrix": [1e-306, 0, 0, 1e-306, 0, 0]})).unwrap(); + let before = text(&s, id); + let history = s.doc().unwrap().history.undo.len(); + let error = s.execute("text.setStyle", &json!({"size": 160})).unwrap_err(); + assert!(matches!(error, EngineError::BadParams { .. }), "reject before attempting unsafe layout: {error}"); + assert_eq!(text(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), history); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index bba0a7c1b..0ebf2e141 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -200,15 +200,21 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay } /// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which -/// it fits, searched by bisection over the first run's size in steps of 0.1 pt (so its scaled size +/// it fits, searched by bisection over the first run's document size in steps of 0.1 pt (so its scaled size /// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there, /// still overflowing. None (keep the full-size layout) when the text has no usable size. fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option { - let size = t.runs.first().map(|r| r.style.size).filter(|s| s.is_finite() && *s >= 0.1)?; + // Exact size edits after object scaling may store a local size below 0.1. Fit in the + // same document points as the Character controls, independent of that representation. + let points = t.style_scale()?.points; + let size = t.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?; if !(min.is_finite() && min < 1.0) { return None; } let tenths = size * 10.0; + if !tenths.is_finite() { + return None; + } // Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow // unless it is below the full size; `lo` is the smallest allowed. let mut lo = (tenths * min).ceil().max(1.0); @@ -234,7 +240,7 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> } // Invariant: everything at `hi` or above overflows; `lo` fits or is the floor. while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES { - let mid = ((lo + hi) * 0.5).floor(); + let mid = (lo + (hi - lo) * 0.5).floor(); let l = at(mid); passes += 1; if l.overflow { diff --git a/crates/text/src/tests_fit.rs b/crates/text/src/tests_fit.rs index 195d6ccb5..f0ede1003 100644 --- a/crates/text/src/tests_fit.rs +++ b/crates/text/src/tests_fit.rs @@ -54,6 +54,19 @@ fn shrink_text_makes_overflowing_text_fit() { assert_eq!(layout(db(), &t).fit_scale, l.fit_scale); } +#[test] +fn scaled_area_shrink_accepts_small_local_sizes_and_uses_document_tenths() { + // A 14 pt story represented in a scaled local coordinate system. Its local size is + // below 0.1, as happens when setting an exact size after enlarging a text group. + let mut t = area(COPY, 0.014, Rect::new(0.0, 0.0, 0.2, 0.09), shrink(30.0)); + t.xf = Affine::scale(1000.0); + assert!(at_size(&t, 0.014).overflow); + let l = layout(db(), &t); + assert!(l.fit_scale < 1.0 && l.fit_scale >= 0.3, "{}", l.fit_scale); + let document_size = 14.0 * l.fit_scale; + assert!((document_size * 10.0 - (document_size * 10.0).round()).abs() < 1e-6); +} + #[test] fn shrink_text_respects_its_minimum() { let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(90.0)); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 9405ab8fe..607ae8726 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{CharStyle, NodeId, NodeKind}; use vectorcraft_tools::{Mods, ToolKey}; -use super::{first_selected, pstate, set_pstate}; +use super::{pstate, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; use crate::widgets::{self, SpinPick, menu_item}; @@ -20,26 +20,62 @@ pub const SIZE_PRESETS: [f64; 15] = [6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, pub const TRACKING_PRESETS: [f64; 14] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0]; pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0]; -/// The character style shown by the panel: the selected range's (or the caret's) while the Type -/// tool edits text, else the first selected text object's first run. With it, the paragraph +/// The stored character style: the selected range's (or the caret's) while the Type +/// tool edits text, else the first selected text object's first run (including groups). With it, the paragraph /// attributes of the paragraph the selection starts in (else of the first paragraph). +/// Font/paragraph controls and named-style capture use this; dimensional fields use `shared_effective`. pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft_doc::ParaStyle)> { if let Some((id, a, b)) = text_editing(app) && let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind) { return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para_at(t.paragraphs_in(a, a).start).clone())); } - let n = first_selected(app)?; - match &n.kind { - NodeKind::Text(t) => Some((t.first_style(), t.para_at(0).clone())), - _ => None, + let t = first_selected_text(app)?; + Some((t.first_style(), t.para_at(0).clone())) +} + +/// The first selected text descendant, shared by Character and Paragraph controls. +pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_doc::TextObject> { + let st = app.session.active()?; + for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + let mut text = None; + node.walk(&mut |n| { + if text.is_none() + && let NodeKind::Text(t) = &n.kind + { + text = Some(t.as_ref()); + } + }); + if text.is_some() { + return text; + } } + None } /// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the /// caret's style at a caret), else every run of the selected type, groups' included. `None` (a /// blank field) where they differ, or with no type. pub(crate) fn shared(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option { + shared_styles(app, |_, style| Some(f(style))) +} + +/// Dimensional controls use document units and tolerate arithmetic roundoff when comparing +/// compensated runs. Keep raw styles available to font/paragraph controls and style definitions, +/// even if a collapsed affine prevents editing dimensions. +fn shared_effective(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> f64) -> Option { + shared_styles(app, |t, style| t.effective_char_style(style).map(|s| Metric(f(&s)))).map(|v| v.0) +} + +struct Metric(f64); + +impl PartialEq for Metric { + fn eq(&self, other: &Self) -> bool { + (self.0 - other.0).abs() <= 1e-9 * self.0.abs().max(other.0.abs()).max(1.0) + } +} + +fn shared_styles(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc::TextObject, &CharStyle) -> Option) -> Option { let st = app.session.active()?; let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) { Some(NodeKind::Text(t)) => Some(t), @@ -49,26 +85,26 @@ pub(crate) fn shared(app: &VectorcraftApp, f: impl Fn(&CharStyle) if let Some((id, a, b)) = text_editing(app) { let t = text(id)?; if b <= a { - return Some(f(&vectorcraft_text::edit::insertion_style(&t.runs, a, b))); + return f(t, &vectorcraft_text::edit::insertion_style(&t.runs, a, b)); } let mut end = 0; for r in &t.runs { let start = end; end += r.text.len(); if start < b && end > a { - acc.add(f(&r.style)); + acc.add(f(t, &r.style)); } } } else { for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { n.walk(&mut |c| { if let NodeKind::Text(t) = &c.kind { - t.runs.iter().for_each(|r| acc.add(f(&r.style))); + t.runs.iter().for_each(|r| acc.add(f(t, &r.style))); } }); } } - acc.value() + acc.value().flatten() } /// The value a run of styles share so far ([`shared`]). @@ -402,7 +438,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & egui::Grid::new(id).num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| { cell(ui, "T", tl!("Font Size"), |ui| size_field(app, ui, "ch-size", fw)); cell(ui, "A↕", tl!("Leading"), |ui| { - let leading = shared(app, CharStyle::effective_leading); + let leading = shared_effective(app, CharStyle::effective_leading); let auto = shared(app, |s| s.leading.is_none()) == Some(true); match widgets::spin_field_auto(ui, "ch-lead", leading, Some(auto), unit, fw, 1.0, 0.1, &SIZE_PRESETS) { Some(SpinPick::Value(v)) => style(app, json!({"leading": v})), @@ -427,18 +463,18 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & return; } cell(ui, "IT", tl!("Vertical Scale %"), |ui| { - if let Some(v) = widgets::spin_plain(ui, "ch-vs", s.v_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { + if let Some(v) = widgets::spin_plain(ui, "ch-vs", shared(app, |s| s.v_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { format(app, json!({"vScale": v})); } }); cell(ui, "T↔", tl!("Horizontal Scale %"), |ui| { - if let Some(v) = widgets::spin_plain(ui, "ch-hs", s.h_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { + if let Some(v) = widgets::spin_plain(ui, "ch-hs", shared_effective(app, |s| s.h_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { format(app, json!({"hScale": v})); } }); ui.end_row(); cell(ui, "Aª", tl!("Baseline Shift"), |ui| { - if let Some(v) = widgets::spin_field(ui, "ch-bs", Some(s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "ch-bs", shared_effective(app, |s| s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) { format(app, json!({"baselineShift": v})); } }); @@ -454,7 +490,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & /// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or /// picked from the presets, in the type unit, blank where the selected text's sizes differ. pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) { - let size = shared(app, |s| s.size); + let size = shared_effective(app, |s| s.size); if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) { style(app, json!({"size": v})); } @@ -646,6 +682,81 @@ mod tests { use super::*; use vectorcraft_engine::Session; + #[test] + fn scaled_dimensions_do_not_show_false_mixed_values_from_roundoff() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone(); + app.run("object.scale", json!({"sx": 120, "sy": 110})).unwrap(); + let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("text.setStyle", json!({"size": 7, "leading": 9})).unwrap(); + app.run("text.setFormat", json!({"hScale": 130, "baselineShift": 3})).unwrap(); + for (actual, expected) in [ + (shared_effective(&app, |s| s.size), 7.0), + (shared_effective(&app, CharStyle::effective_leading), 9.0), + (shared_effective(&app, |s| s.h_scale), 130.0), + (shared_effective(&app, |s| s.baseline_shift), 3.0), + ] { + assert!((actual.expect("equal dimensions must not be mixed") - expected).abs() < 1e-8); + } + } + + #[test] + fn scaled_collapsed_text_keeps_nondimensional_controls_available() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap(); + app.run("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap(); + assert!(text_style(&app).is_some()); + assert_eq!(shared(&app, |s| s.tracking), Some(0.0)); + assert_eq!(shared_effective(&app, |s| s.size), None); + app.run("text.setStyle", json!({"tracking": 40})).unwrap(); + app.run("text.setFormat", json!({"leftIndent": 4})).unwrap(); + assert_eq!(shared(&app, |s| s.tracking), Some(40.0)); + assert_eq!(text_style(&app).unwrap().1.left_indent, 4.0); + let ctx = egui::Context::default(); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + assert!(!texts.iter().any(|(s, _)| s == "No text selected")); + } + + #[test] + fn scaled_character_fields_show_document_sizes_and_mixed_group_values() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone(); + app.run("object.scale", json!({"sx": 200})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.size), Some(24.0)); + assert_eq!(text_style(&app).unwrap().0.size, 12.0, "style definitions keep stored attributes"); + let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("object.group", json!({})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.size), Some(24.0), "different local sizes, same displayed size"); + app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.h_scale), Some(200.0)); + app.run("text.setStyle", json!({"size": 10})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.size), Some(10.0)); + app.run("text.setFormat", json!({"hScale": 100})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.h_scale), Some(100.0)); + } + + #[test] + fn scaled_range_fields_follow_the_type_tool_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis label", "size": 12})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(text_editing(&app).is_some()); + app.run("object.scale", json!({"sx": 200})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 4, "size": 10})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_effective(&app, |s| s.size), None); + app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); + assert_eq!(shared_effective(&app, |s| s.size), Some(10.0)); + assert_eq!(text_style(&app).unwrap().0.size, 5.0); + } + /// Bold and Italic pick the family's own faces by name, and toggle back (#724). #[test] fn bold_and_italic_pick_the_familys_own_faces() { diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index ed9f2a627..023b3189f 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -42,9 +42,11 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) { /// right to left: its Paragraph Direction, else from its first strong character? fn resolved_rtl(app: &VectorcraftApp, para: &ParaStyle) -> bool { let editing = super::character::text_editing(app); - let selected = super::first_selected(app); - let node = editing.and_then(|(id, _, _)| app.session.active().and_then(|d| d.doc.node(id))).or(selected.as_ref()); - let Some(NodeKind::Text(t)) = node.map(|n| &n.kind) else { return para.direction == Some(ParaDirection::RightToLeft) }; + let text = editing.and_then(|(id, _, _)| match app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t.as_ref()), + _ => None, + }); + let Some(t) = text.or_else(|| super::character::first_selected_text(app)) else { return para.direction == Some(ParaDirection::RightToLeft) }; let plain = t.plain_text(); let paragraph = vectorcraft_text::edit::paragraph_at(&plain, editing.map_or(0, |(_, a, _)| a)); vectorcraft_text::paragraph_is_rtl(plain.get(paragraph).unwrap_or_default(), para.direction) @@ -246,6 +248,8 @@ mod tests { shown_alignment(para.justify, resolved_rtl(app, ¶)) }; assert_eq!(shown(&app), Justify::Right); + app.session.execute("object.group", &json!({})).unwrap(); + assert_eq!(shown(&app), Justify::Right, "group controls resolve the same text descendant's direction"); app.session.execute("text.setStyle", &json!({"justify": "left"})).unwrap(); assert_eq!(shown(&app), Justify::Left); app.session.execute("text.setStyle", &json!({"justify": "auto"})).unwrap(); diff --git a/crates/ui-egui/src/panels/tests_fontsize.rs b/crates/ui-egui/src/panels/tests_fontsize.rs index e8f8d57ea..39baa95cc 100644 --- a/crates/ui-egui/src/panels/tests_fontsize.rs +++ b/crates/ui-egui/src/panels/tests_fontsize.rs @@ -61,6 +61,38 @@ fn a_picked_font_size_preset_applies_in_one_undo_step() { assert_eq!(undo_len(&app), undo + 1, "one undo step"); } +#[test] +fn scaled_font_size_fields_and_preset_use_document_points() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (mut app, id) = app_with_text(); + run(&mut app, "object.scale", json!({"sx": 200})); + assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt")); + assert!(shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "24 pt")); + let at = Cell::new(egui::Pos2::ZERO); + frame_events(&ctx, &mut app, vec![], size_combo(&at)); + click(&ctx, &mut app, at.get() + egui::vec2(110.0, 13.0), size_combo(&at)); + let t = frame_events(&ctx, &mut app, vec![], size_combo(&at)); + let undo = undo_len(&app); + click(&ctx, &mut app, text_rect(&t, "10 pt").center(), size_combo(&at)); + assert_eq!(style(&app, id).size * 2.0, 10.0); + assert_eq!(undo_len(&app), undo + 1); + assert!(shows(&frame_events(&ctx, &mut app, vec![], size_combo(&at)), "10 pt")); +} + +#[test] +fn scaled_group_shows_character_and_control_bar_size_fields() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (mut app, _) = app_with_text(); + run(&mut app, "object.group", json!({})); + run(&mut app, "object.scale", json!({"sx": 200})); + assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt")); + assert!(shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "24 pt")); + run(&mut app, "text.setStyle", json!({"size": 10})); + assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "10 pt")); +} + #[test] fn font_size_presets_show_in_the_type_unit() { let ctx = egui::Context::default(); diff --git a/docs/gaps.md b/docs/gaps.md index f27098dc0..2b03706c4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (moved from the ROADMAP's "Where we're lacking", re-ranked, added file-format, stability, hardware, localization, ecosystem and AI gaps) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -86,6 +86,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G11 Correctness bugs in core tools +- **Done:** scaled text's size and horizontal-scale controls account for object/group transforms (#1034), + including exact sizes across grouped labels and resetting horizontal stretch while keeping editable text. - **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Transform effect dialog's invisible checkboxes and missing options (#885); PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834). - **Impact:** wrong output in everyday work. @@ -96,7 +98,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Missing:** hyphenation and justification options, Optical Margin Alignment (type core); CJK vertical composition (ruby, mojikumi sets and dialog, proportional vertical metrics #966, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern features beyond bidi, variable font axes, spell check with an open dictionary, - Touch Type, Retype, Snap to Glyph. Itemized in [type-parity.md](type-parity.md). + Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement). + Itemized in [type-parity.md](type-parity.md). - **Impact:** professional typography and Japanese/Chinese publishing. - **Estimate:** 34–47 h (type core 14–19 h blocks beta). @@ -200,6 +203,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | #1034: corrected scaled type controls; optional group font-size preservation remains separate. Estimates unchanged at this rounding. | | 2026-10-11 | minor | G9: legacy `.ai` without the prolog opens with its layers (#1027) | | 2026-10-11 | minor | G9: `.ai` pattern fills and global colours read from the editing data (#1025) | | 2026-10-10 | minor | G3: initial Revolve landed (#846), 45–75 h left | diff --git a/docs/roadmap.md b/docs/roadmap.md index a5f7653a7..03b9ec6a3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (added the Alpha gate table; earlier the same day: moved the milestone table and direction here from ROADMAP.md, added Current focus) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (M7: accurate character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -54,7 +54,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | @@ -79,6 +79,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14 | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | M7: document-point character sizes, leading and baseline shift, and resettable horizontal stretching after object/group scaling (#1034). Optional group font-size preservation remains separate; scores/hours unchanged. | | 2026-10-10 | minor | M9 row from main: initial Revolve | | 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha | | 2026-10-10 | major | Created from ROADMAP.md's milestone table and "Where we're going"; added Current focus and the beta list | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a766deb75..87fd35fb0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (#1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -76,7 +76,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated | | Raster effects (Effect Gallery) | 4 | 48% | 29 Photoshop-style filters (Artistic, Sketch), the Effect Gallery dialog, Load Texture | 24–40 | measured count 28/57 | | 3D and Materials (and Turntable) | 4 | ~8% | initial live Revolve done (flat shading, one light, no caps, approximate visibility for intersecting surfaces); Extrude & Bevel, Inflate, Rotate, materials, Turntable views, 3D export | 50–85 | estimated (Revolve landed after the first measure, #846) | -| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, see [type-parity.md](type-parity.md) | 14–19 | estimated | +| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi, proportional vertical metrics), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated | | Symbols, blends, envelopes, Repeat, perspective | 5 | 85% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape | 6–10 | estimated (lowered from 88%: target moved) | | Image Trace, graphs, image tools | 3 | 75% | graph Design/Column/Marker, Image Trace Create Strokes depth (Create Object Mosaic is done) | 5–8 | estimated (raised from 72%) | @@ -305,7 +305,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 | | 3D and Materials | 4 | ~8% | Initial Revolve only, with simple lighting and Revolve-specific visible-surface expansion; Extrude & Bevel, Inflate, Rotate, caps, materials, shadows and more complete visibility remain | 45–75 | -| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 | +| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034) | 14–19 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | | Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), 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 | @@ -419,7 +419,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). -- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). +- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. @@ -458,6 +458,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | 2026-10-10 | minor | Added the essentials-user readiness score (~75%): 16 core features weighted, depth ~91%, discounted for launch, discoverability and files people send | | 2026-10-10 | minor | Second look at ready for real work at the owner's request: 50% → 55% full Illustrator, and a new mainstream-illustrator figure of 65%. Why: the old 25-point discount was a judgement with no breakdown. Now each discount is written down and checked against the tracker: 86% of 79 core-drawing issues closed, 20 of 28 `.ai`/EPS issues fixed and the open ones about structure, praise from 15 people, no reports of switching; dimensions a typical illustrator rarely hits are taken out of readiness. Feature breadth, depth and hours unchanged | | 2026-10-10 | minor | Stage checked against the core-workflow gate: passes, stays alpha | +| 2026-10-10 | minor | #1034: accurate transformed character controls; type-core score/hours and headline estimates unchanged at this rounding. | | 2026-10-10 | major | Full re-measure against Illustrator 2026 30.x from code counts, open issues and public docs; added breadth/depth/ready-for-real-work numbers, generative-AI row, calibration from PR timestamps; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file | | 2026-10-05 | major | (in ROADMAP.md) M3 and M4 rows re-derived after the paint and files milestones | | 2026-10-02 | major | (in ROADMAP.md) First 22-area weighted estimate | diff --git a/docs/type-parity.md b/docs/type-parity.md index f552e1a3b..e74f805da 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, split out of the parity table's two type rows) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (document-point character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -23,6 +23,8 @@ against Illustrator's public documentation; **estimated** unless noted. | Threaded text, Fit Headline, Text Wrap | done | | | Type on a Path effects and options; brackets | done | #429 | | Character and Paragraph panels, styles with overrides | done | mixed-value display for differing paragraphs missing | +| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | +| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference unused (#394) | | Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339 | | Variable fonts | partial | named instances as styles; axis sliders missing | @@ -48,4 +50,5 @@ against Illustrator's public documentation; **estimated** unless noted. | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | #1034: transformed character controls and regression coverage; preserving group font sizes remains a separate enhancement. Scores/hours unchanged at this rounding. | | 2026-10-10 | major | First checklist, split out of the parity table's Type core and Type advanced rows | From 621e1a6640dff383fd9205483454b93273c0d02e Mon Sep 17 00:00:00 2001 From: imachus <51533227+imachus@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:23:54 +0900 Subject: [PATCH 76/97] =?UTF-8?q?Type:=20Proportional=20Metrics=20?= =?UTF-8?q?=E2=80=94=20full-width=20glyphs=20on=20the=20font's=20proportio?= =?UTF-8?q?nal=20widths,=20`palt`=20(#966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A character attribute (Character panel menu with the East Asian options; `text.setFormat` / `text.setRangeStyle {proportionalMetrics}`), saved only when on. In horizontal type it applies `palt`; vertical type sets as before until the vertical shaping change (`vpal`) follows. Line-end Punctuation Half Width takes nothing more off the punctuation `palt` re-spaced (the flush opening bracket, consecutive punctuation, the line-end half width): the segment is shaped once more without `palt`, and the glyphs whose advance differs are marked. Punctuation the font leaves full width is trimmed as before. SVG writes `"palt" 1` with the character's other features. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/doc/src/text.rs | 6 +++ crates/engine/src/cmd/panelcmds.rs | 6 ++- crates/engine/src/cmd/textedit.rs | 8 ++- crates/engine/src/cmd/typecmd.rs | 28 +++++++++++ crates/svg/src/css.rs | 22 +++++---- crates/svg/tests/text_layout.rs | 14 ++++++ crates/text/src/features.rs | 17 +++++++ crates/text/src/layout.rs | 4 +- crates/text/src/shape.rs | 68 +++++++++++++++++--------- crates/text/src/tests_typo.rs | 45 +++++++++++++++++ crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/character.rs | 18 +++++-- docs/mcp.md | 13 +++++ docs/target-app-parity.md | 2 +- 24 files changed, 220 insertions(+), 43 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 42ffe80b0..73b5e00ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/doc/src/text.rs b/crates/doc/src/text.rs index 7ed8247b9..012e64b2b 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -95,6 +95,11 @@ pub struct CharStyle { /// their line line up with it. #[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")] pub char_align: CharAlign, + /// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the + /// font's own proportional widths, OpenType `palt` (#966). Vertical type sets them as before + /// for now. + #[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")] + pub proportional_metrics: bool, } /// Where a character smaller than the largest on its line lines up with it: on the Roman @@ -205,6 +210,7 @@ impl Default for CharStyle { stroke_dash: None, position: CharPosition::Normal, char_align: CharAlign::RomanBaseline, + proportional_metrics: false, small_caps: None, } } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index fe1c4d642..62204f755 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -215,6 +215,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "direction", "leadingModel", "charAlign", + "proportionalMetrics", "burasagari", "composer", ]; @@ -314,6 +315,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = char_align { st.char_align = v; } + if let Some(v) = flag("proportionalMetrics") { + st.proportional_metrics = v; + } }); let span = super::typecmd::para_span(range, t); t.edit_paras(span, |para| { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 3a2593ac3..8d6ec5482 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{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 (size/leading/baselineShift are document points and hScale includes the object transform; 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\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}", has_doc, set_range_style ), @@ -208,6 +208,7 @@ pub(crate) struct CharChange { position: Option, small_caps: Option>, char_align: Option, + proportional_metrics: Option, } /// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). @@ -291,6 +292,7 @@ impl CharChange { position, small_caps, char_align: char_align_param(p, cmd)?, + proportional_metrics: flag("proportionalMetrics"), }; if c.size.is_some_and(|v| v <= 0.0) { return Err(bad(cmd, "size must be positive")); @@ -319,6 +321,7 @@ impl CharChange { && self.position.is_none() && self.small_caps.is_none() && self.char_align.is_none() + && self.proportional_metrics.is_none() } fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> { @@ -405,6 +408,9 @@ impl CharChange { if let Some(v) = self.char_align { st.char_align = v; } + if let Some(v) = self.proportional_metrics { + st.proportional_metrics = v; + } } } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index f75120a66..38f2c3a1c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -859,6 +859,34 @@ mod area_tests { } } + /// Proportional Metrics (#966): a character attribute, saved only when on. + #[test] + fn proportional_metrics_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.proportional_metrics); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.proportional_metrics)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.proportional_metrics); + // Characters selected with the Type tool: only the range takes it (雅 is 3 bytes). + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect(); + assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]); + // Styles written without it (documents from before it) read as off. + let old = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert!(old.get("proportionalMetrics").is_none()); + assert!(!serde_json::from_value::(old).unwrap().proportional_metrics); + } + #[test] fn new_type_is_composed_with_line_end_half_width_punctuation() { use vectorcraft_doc::Mojikumi; diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index f98092b65..8775a97d8 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -109,15 +109,19 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { // Manual kerning replaces the font's pair kerning. p.push(("font-kerning", "none".into())); } - if !st.features.is_empty() { - let v: Vec = st - .features - .iter() - .map(|t| match t.strip_prefix('-') { - Some(off) => format!("\"{off}\" 0"), - None => format!("\"{t}\" 1"), - }) - .collect(); + let mut v: Vec = st + .features + .iter() + .map(|t| match t.strip_prefix('-') { + Some(off) => format!("\"{off}\" 0"), + None => format!("\"{t}\" 1"), + }) + .collect(); + // Proportional Metrics: `palt` (SVG writes vertical type as outlines). + if st.proportional_metrics { + v.push("\"palt\" 1".into()); + } + if !v.is_empty() { p.push(("font-feature-settings", v.join(", "))); } match (st.underline, st.strikethrough) { diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index c6587ab27..94f4dee5f 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -105,6 +105,20 @@ fn rich_text_writes_spacing_shift_and_scale() { assert_eq!(attr(tags[1], "y"), attr(tags[0], "y")); } +/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own. +#[test] +fn proportional_metrics_write_palt() { + let proportional = CharStyle { proportional_metrics: true, ..style(12.0) }; + let both = CharStyle { proportional_metrics: true, features: vec!["dlig".into()], ..style(12.0) }; + let d = area_doc(vec![run("雅楽 ", style(12.0)), run("「あ」 ", proportional), run("ab", both)], 280.0, Justify::Left); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert_eq!(tags.len(), 3, "{svg}"); + assert!(!tags[0].contains("palt"), "{}", tags[0]); + assert!(tags[1].contains("font-feature-settings:"palt" 1"), "{}", tags[1]); + assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]); +} + #[test] fn fewer_tspans_writes_one_per_line() { let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) }; diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 1cf826302..88bf919d5 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -160,6 +160,23 @@ impl OtFeatures { // and digits itself (they would lie upside down, and tate-chu-yoko break). v.push(f(b"vert", true)); } + if self.proportional(st) { + v.push(f(b"palt", true)); + } + v + } + + /// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in + /// horizontal type; vertical type (`vpal`) needs vertical shaping, so it sets as before. + pub(crate) fn proportional(&self, st: &CharStyle) -> bool { + st.proportional_metrics && !self.vertical + } + + /// The harfrust features for text in style `st` with full-width glyphs on their full widths: + /// [`Self::resolve`] without `palt`. + pub(crate) fn resolve_fixed_width(&self, st: &CharStyle) -> Vec { + let mut v = self.resolve(st); + v.retain(|x| x.tag != Tag::new(b"palt")); v } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 0ebf2e141..338820588 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -387,7 +387,8 @@ fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) { /// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its /// advance an em, give or take a tenth), the space that mojikumi can take off. fn punct_half(g: &SGlyph, kind: Punct) -> Option { - if punct(g.ch)? != kind || g.tcy.is_some() { + // A glyph Proportional Metrics re-spaced has no empty half left to take off (#966). + if punct(g.ch)? != kind || g.tcy.is_some() || g.proportional { return None; } let em = g.face.units_per_em(); @@ -1176,6 +1177,7 @@ mod wrapping_tests { tcy: None, inline: None, lead: 0.0, + proportional: false, level: unicode_bidi::Level::ltr(), }) .collect() diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index f82f447cd..328fcac17 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -49,6 +49,9 @@ pub(crate) struct SGlyph { /// Japanese composition: the space taken off before the glyph (an opening bracket after /// another, see [`crate::layout`]); the glyph is drawn that much earlier on the line. pub lead: f64, + /// Proportional Metrics changed the glyph's advance (`palt`): it has its proportional width, + /// and Japanese composition takes no more space off it (#966). + pub proportional: bool, /// Resolved Unicode bidi embedding level (logical source order). pub level: Level, /// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) standing in for a glyph. @@ -117,6 +120,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph { h.len = 0; h.ch = '-'; h.inline = None; + h.proportional = false; h } @@ -422,6 +426,7 @@ fn inline_glyph(face: &Arc, st: &CharStyle, art: &InlineArt, run: usiz ch: text.get(range.clone()).and_then(|s| s.chars().next()).unwrap_or(INLINE_CHAR), tcy: None, lead: 0.0, + proportional: false, // U+FFFC is a bidi neutral (ON): its level, resolved from its neighbours, places it in // RTL text like any other glyph once the line is reordered. level, @@ -470,34 +475,48 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' '); let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff + // Proportional Metrics: which glyphs `palt` gave another advance than their full width. + let mut proportional = vec![]; let shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); - let mut buf = UnicodeBuffer::new(); - for (i, c) in text_seg.char_indices() { - let cl = (range.start + i) as u32; - if upper { - for u in c.to_uppercase() { - buf.add(u, cl); + let shape = |feats: &[Feature]| { + let mut buf = UnicodeBuffer::new(); + for (i, c) in text_seg.char_indices() { + let cl = (range.start + i) as u32; + if upper { + for u in c.to_uppercase() { + buf.add(u, cl); + } + } else { + buf.add(c, cl); } - } else { - buf.add(c, cl); } - } - buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight }); - buf.guess_segment_properties(); - // Harfrust keeps this Unicode context for joining/positional shaping, but does not emit - // glyphs for it. This retains context at real style/font segmentation boundaries without - // shaping across those boundaries or claiming cross-style ligatures/mark attachment. - if let Some(pre) = text.get(context.start..range.start) { - buf.set_pre_context(pre); - } - if let Some(post) = text.get(range.end..context.end) { - buf.set_post_context(post); - } - let feats: Vec = feats.resolve(st); - let gb = shaper.shape(buf, ShapeOptions::new().features(&feats)); - for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { - raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset)); + buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight }); + buf.guess_segment_properties(); + // Harfrust keeps this Unicode context for joining/positional shaping, but does not emit + // glyphs for it. This retains context at real style/font segmentation boundaries without + // shaping across those boundaries or claiming cross-style ligatures/mark attachment. + if let Some(pre) = text.get(context.start..range.start) { + buf.set_pre_context(pre); + } + if let Some(post) = text.get(range.end..context.end) { + buf.set_post_context(post); + } + let gb = shaper.shape(buf, ShapeOptions::new().features(feats)); + gb.glyph_infos() + .iter() + .zip(gb.glyph_positions()) + .map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset)) + .collect::>() + }; + raw = shape(&feats.resolve(st)); + if feats.proportional(st) { + // `palt` is positioning only: the same glyphs set on their full widths tell which + // advances it changed (kerning and the rest change both alike). + let full = shape(&feats.resolve_fixed_width(st)); + if full.len() == raw.len() && full.iter().zip(&raw).all(|(a, b)| a.0 == b.0) { + proportional = full.iter().zip(&raw).map(|(a, b)| a.2 != b.2).collect(); + } } }); if shaped.is_none() { @@ -553,6 +572,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe ch, tcy: None, lead: 0.0, + proportional: proportional.get(gi).copied().unwrap_or(false), level: *level, inline: None, }); diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index 4897e53be..5bf85d8a0 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -749,3 +749,48 @@ fn layout_10k_area_text_is_fast() { assert!(per < budget, "{justify:?} {composer:?}: {per} ms"); } } + +/// Proportional Metrics (#966): in horizontal type full-width glyphs take the font's proportional +/// widths (`palt`), and Japanese composition takes nothing more off the punctuation `palt` +/// re-spaced (the flush bracket at a paragraph's start, consecutive punctuation, half width at a +/// line's end); punctuation it leaves full width is trimmed as before. Vertical type sets as +/// before. Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not +/// 〘〙 (skipped without it). +#[test] +fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() { + use vectorcraft_doc::Mojikumi; + if db().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let st = |on: bool| CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), proportional_metrics: on, ..CharStyle::default() }; + // Right-aligned in a frame ten ems wide, so a line's end trim moves the line. + let lay = |text: &str, on: bool, m: Mojikumi, vertical: bool| { + let mut t = area(text, st(on), Rect::new(0.0, 0.0, 200.0, 200.0), Justify::Right); + t.vertical = vertical; + t.para.mojikumi = m; + layout(db(), &t) + }; + let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::>(); + let x0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().x0).collect::>(); + let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01); + let off = adv(&lay("「あ漢」", false, Mojikumi::None, false)); + let on = adv(&lay("「あ漢」", true, Mojikumi::None, false)); + assert!(off.iter().all(|a| (a - 20.0).abs() < 0.01), "{off:?}"); + assert!(on[0] < 15.0 && on[1] < 19.0 && on[3] < 15.0, "kana and brackets narrower: {on:?}"); + assert!((on[2] - 20.0).abs() < 0.01, "the kanji keeps its em: {on:?}"); + // Re-spaced punctuation: Line-end Punctuation Half Width changes nothing, at the paragraph's + // start (「), between punctuation (」、) or at the line's end (」). + for text in ["「あ」、漢", "漢「あ」"] { + let (half, none) = (lay(text, true, Mojikumi::LineEndHalf, false), lay(text, true, Mojikumi::None, false)); + assert!(near(&adv(&half), &adv(&none)), "{text}: {:?} {:?}", adv(&half), adv(&none)); + assert!(near(&x0(&half), &x0(&none)), "{text}: {:?} {:?}", x0(&half), x0(&none)); + } + // Punctuation `palt` leaves full width: flush at the paragraph's start, half width at the end. + let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf, false), lay("〘あ〙", true, Mojikumi::None, false)); + assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half)); + let moved = x0(&half)[1] - x0(&none)[1]; + assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}"); + // Vertical type sets as before. + assert!(near(&adv(&lay("「あ漢」", true, Mojikumi::None, true)), &adv(&lay("「あ漢」", false, Mojikumi::None, true)))); +} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d4e5e6f6e..8599f203f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2000,6 +2000,7 @@ Proof Colors Farbproof Proof Setup Proof einrichten Properties Eigenschaften + Proportional Metrics Proportionale Metrik Proportional Oldstyle Proportionale Mediävalziffern Pucker & Bloat… Zusammenziehen und aufblasen… Pucker Tool Zusammenziehen-Werkzeug diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6bb2aa3f7..e1dc04199 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2051,6 +2051,7 @@ Proof Colors Colores de prueba Proof Setup Ajuste de prueba Properties Propiedades + Proportional Metrics Métrica proporcional Proportional Oldstyle Estilo antiguo proporcional Pucker & Bloat… Fruncir y engordar… Pucker Tool Herramienta Fruncir diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 23092e6d3..41a1a44d5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2048,6 +2048,7 @@ Proof Colors Couleurs d’épreuve Proof Setup Format d’épreuve Properties Propriétés + Proportional Metrics Métrique proportionnelle Proportional Oldstyle Elzévirien proportionnel Pucker & Bloat… Contracter et dilater… Pucker Tool Outil Contraction diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c76bd87db..12eded13d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2051,6 +2051,7 @@ Proof Colors Colori di prova Proof Setup Impostazione prova Properties Proprietà + Proportional Metrics Metrica proporzionale Proportional Oldstyle Proporzionali stile antico Pucker & Bloat… Contrai e gonfia… Pucker Tool Strumento Contrai diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5835f335d..8baf8dc60 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -37,6 +37,7 @@ Em Box Top/Right 仮想ボディの上 / 右 ICF Top/Right 平均字面の上 / 右 ICF Bottom/Left 平均字面の下 / 左 + Proportional Metrics プロポーショナルメトリクス Favorites お気に入り Flip 反転 Japanese 日本語 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 40d715df7..b5039cb26 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1721,6 +1721,7 @@ Proof Colors Provar Cores Proof Setup Configuração de Prova Properties Propriedades + Proportional Metrics Métrica Proporcional Pucker & Bloat… Encolher e Inchar… Pucker Tool Ferramenta Encolher Punctuation Space Espaço de Pontuação diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e324fd011..f218c5c3f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2047,6 +2047,7 @@ Proof Colors Цветопроба Proof Setup Настройка цветопробы Properties Свойства + Proportional Metrics Пропорциональные метрики Proportional Oldstyle Пропорциональные старого стиля Pucker & Bloat… Сморщивание и раздувание… Pucker Tool Инструмент «Сморщивание» diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c8410593f..e01e389c2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2043,6 +2043,7 @@ Proof Colors Пробні кольори Proof Setup Налаштування кольоропроби Properties Властивості + Proportional Metrics Пропорційні метрики Proportional Oldstyle Пропорційні цифри старого стилю Pucker & Bloat… Зморщування та роздування … Pucker Tool Інструмент «Зморщування» diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c4ed3cd12..e293f0b92 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1976,6 +1976,7 @@ Proof Colors 校样颜色 Proof Setup 校样设置 Properties 属性 + Proportional Metrics 比例度量 Proportional Oldstyle 比例旧式数字 Pucker & Bloat… 缩拢与膨胀… Pucker Tool 收缩工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2827e6141..784465963 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2029,6 +2029,7 @@ Proof Colors 校樣色彩 Proof Setup 校樣設定 Properties 內容 + Proportional Metrics 比例度量 Proportional Oldstyle 比例舊式數字 Pucker & Bloat… 縮攏與膨脹… Pucker Tool 縮攏工具 diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 607ae8726..a255d8ca2 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -655,6 +655,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // Proportional Metrics: full-width glyphs on the font's proportional widths (`palt`). + let proportional = s.as_ref().is_some_and(|s| s.proportional_metrics); + if menu_item(ui, tl!("Proportional Metrics"), has, proportional) { + format(app, json!({"proportionalMetrics": !proportional})); + } ui.separator(); } for l in ["Standard Vertical Roman Alignment", "Tate-chu-yoko", "Fractional Widths", "System Layout", "No Break"] { @@ -784,17 +789,20 @@ mod tests { assert!(!crate::menus::enabled(&app, "type.bold")); } - /// Character Alignment is in the panel menu with the East Asian options only (as Mojikumi Set - /// and Top-to-Top Leading are in the Paragraph panel). + /// Character Alignment and Proportional Metrics are in the panel menu with the East Asian + /// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel). #[test] fn character_alignment_shows_with_the_east_asian_options() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "雅楽"})).unwrap()["id"].clone(); app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Alignment"); - assert!(!shown(&mut app)); + let shown = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, menu); + (text.contains("Character Alignment"), text.contains("Proportional Metrics")) + }; + assert_eq!(shown(&mut app), (false, false)); app.session.prefs.show_east_asian_options = true; - assert!(shown(&mut app)); + assert_eq!(shown(&mut app), (true, true)); } } diff --git a/docs/mcp.md b/docs/mcp.md index 5650634fc..d74f20839 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2627,6 +2627,19 @@ without ideographs uses its em box. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"charAlign":"icfTop"}}} ``` +## Proportional Metrics + +`text.setFormat {proportionalMetrics: true}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, +proportionalMetrics}` (a range) set full-width glyphs on the proportional widths the font gives them with the +OpenType `palt` feature; glyphs the font doesn't re-space keep their full width. Line-end Punctuation Half Width then takes nothing more off the punctuation `palt` re-spaced (the opening +bracket at a line's start, consecutive punctuation, the closing mark at a line's end); punctuation left full width is +trimmed as before. Off by default and saved only when on. Horizontal type only for now: vertical type sets as before. +One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"proportionalMetrics":true}}} +``` + ## New type in a Japanese interface While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 87fd35fb0..fc9c8d072 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -420,7 +420,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). -- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. +- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type, and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (vertical type's `vpal` not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). From dc6e8a4253b89dfa82a6722760ecfb39f26f2605 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 9 Oct 2026 17:32:42 -0700 Subject: [PATCH 77/97] Object: Align, Move, Arrange and other commands that change nothing record no undo step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These Object commands edited the document even when nothing changed, so the call recorded an undo step, emptied Redo and marked the document modified: - object.align, object.distributeSpacing and object.distribute (three or more objects) when no object moves. With one object selected, Object › Align aligns it to its own bounds, so it recorded a step every time. Calls on objects that were already aligned or evenly spaced also recorded a step, including objects that an earlier Align or Distribute left a rounding error away from the position the next call computes. - Move, Rotate, Scale, Shear, Transform and Nudge by an identity transform without a copy, Transform Again after one, Set Bounds with the current position and size, and a Selection tool drag that ends where it started. - Nudge by 0 with anchors direct-selected or with only ruler guides selected, and Transform with nothing selected or with ids that name no object, with or without a copy. - Bring to Front and Bring Forward on objects already in front, Send to Back and Send Backward on objects already at the back, and Send to Current Layer on objects already on top of the current layer. - Ungroup, Release Compound Path and Release Clipping Mask with none of their objects selected, and Expand Shape with no live shape selected. - Lock with every selected object already locked, Hide with every one already hidden, Unlock All with nothing locked and Show All with nothing hidden. - object.setProps with the values the objects already have, or with no object. - object.setLiveShape with the values the shape already has: a polygon's sides, angle, radius or equal sides, or a side length that gives exactly its current radius; an ellipse's pie, including Invert Pie on a whole ellipse; and a corner radius or kind that the corners already have. These commands now return without editing the document, and Align and Distribute count a move of 1e-9 pt or less on both axes as none. Such a call records no undo step, keeps the step to redo and leaves the document unmodified. In these cases Ungroup no longer resets the selection, and Release Compound Path, Unlock All and Show All no longer empty it. A Transform with a copy of no object keeps the selection. Lock and Hide still deselect the objects. A Nudge by 0 no longer expands a live shape whose anchors are direct-selected. The replies have the same form as before. An identity transform still sets what Transform Again repeats. The journal still records each call, so the Actions panel records it as before. When every value given to Set Bounds is the current one, it now applies the identity transform instead of the transform computed from those values, which can differ from the identity by a rounding error on a rotated object or with Use Preview Bounds. When a drag comes back to its start, the identity preview no longer edits the document. Session::preview now advances the revision when it undoes the previous preview, so views redraw that drag. The undo and redo row of docs/mcp.md now notes that many calls that change nothing record no undo step, so undo after one undoes the step before it. --- ROADMAP.md | 2 +- crates/engine/src/cmd/object.rs | 185 ++++++++++++-- crates/engine/src/lib.rs | 5 + crates/engine/src/tests.rs | 420 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 9 +- docs/mcp.md | 2 +- 6 files changed, 599 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 73b5e00ff..490733ef8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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 2e9127808..832c0d58b 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -251,17 +251,26 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf let areas = bool_or(p, "typeAreas", false); let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() }; sc.patterns = transform_patterns(s, p, &ids)?; - let ids = s.edit(label, |d, sel| { - let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() }; - for id in &targets { - if let Some(n) = d.node_mut(*id) - && !(areas && resize_type_area(n, xf)) - { - n.transform(xf, sc); + // A transform of ids that name no object, or an identity transform without a copy, moves + // nothing: the document and its undo history stay as they are, and Transform Again repeats it. + // With a copy, the reply lists the copies, and there are none. + let d = &s.doc()?.doc; + let none = ids.iter().all(|id| d.node(*id).is_none()); + let ids = if none || (!copy && xf == Affine::IDENTITY) { + if copy { vec![] } else { ids } + } else { + s.edit(label, |d, sel| { + let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() }; + for id in &targets { + if let Some(n) = d.node_mut(*id) + && !(areas && resize_type_area(n, xf)) + { + n.transform(xf, sc); + } } - } - Ok(targets) - })?; + Ok(targets) + })? + }; let st = s.doc_mut()?; if st.interaction.is_none() { st.last_transform = Some((xf, copy)); @@ -300,10 +309,19 @@ fn nudge(s: &mut Session, p: &Value) -> Result { let dx = f64_or(p, "dx", 0.0) * k; let dy = f64_or(p, "dy", 0.0) * k; if !s.doc()?.selection.anchors.is_empty() { + // When the anchors move by 0, the document and its undo history stay as they are. + if dx == 0.0 && dy == 0.0 { + return ok(); + } return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy })); } if s.doc()?.selection.is_empty() { - return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": bool_or(p, "copy", false) })); + let copy = bool_or(p, "copy", false); + // When guides move by 0 without a copy, the document and its undo history stay as they are. + if dx == 0.0 && dy == 0.0 && !copy { + return ok(); + } + return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy })); } let ids = selected_roots(s)?; apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p) @@ -386,7 +404,9 @@ fn arrange(s: &mut Session, how: Arrange) -> Result { Arrange::Backward => "Send Backward", Arrange::Back => "Send to Back", }; - s.edit(label, |d, _| { + // When nothing changes order, the document and its undo history stay as they are. + super::overprint::edit_counted(s, label, |d| { + let mut moved = 0; // Arrange within each parent independently (selections may span layers/groups). let mut by_parent: Vec<(Option, Vec)> = vec![]; for id in &ids { @@ -411,10 +431,11 @@ fn arrange(s: &mut Session, how: Arrange) -> Result { }; if to != idx { d.move_node(*id, par, to)?; + moved += 1; } } } - Ok(()) + Ok(moved) })?; ok() } @@ -424,6 +445,11 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result { // Ids are reused after undo: the remembered current layer must still be a layer. let st = s.doc()?; let layer = st.current_layer().or_else(|| st.doc.default_layer()).ok_or_else(|| EngineError::Other("no current layer".into()))?; + // When the selected objects are already the topmost in the current layer, in paint order, the + // document and its undo history stay as they are. + if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) { + return ok(); + } s.edit("Send to Current Layer", |d, _| { for id in &ids { d.move_node(*id, Some(layer), usize::MAX)?; @@ -457,6 +483,11 @@ pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result { fn ungroup(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; + // With no group selected, the document, the selection and the undo history stay as they are. + let d = &s.doc()?.doc; + if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))) { + return ok(); + } s.edit("Ungroup", |d, sel| { let mut new_sel = vec![]; for id in &ids { @@ -492,6 +523,13 @@ fn ungroup(s: &mut Session, _: &Value) -> Result { fn lock(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; + // With every selected object already locked, the document and its undo history stay as they + // are, and the objects are deselected. + let d = &s.doc()?.doc; + if ids.iter().all(|id| d.node(*id).is_none_or(|n| n.locked)) { + s.select(|_, sel| sel.clear())?; + return ok(); + } s.edit("Lock", |d, sel| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -516,6 +554,10 @@ fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { fn unlock_all(s: &mut Session, _: &Value) -> Result { let ids = collect_flag(&s.doc()?.doc, |n| n.locked); + // With nothing locked, the document, the selection and the undo history stay as they are. + if ids.is_empty() { + return Ok(json!({ "count": 0 })); + } s.edit("Unlock All", |d, sel| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -530,6 +572,13 @@ fn unlock_all(s: &mut Session, _: &Value) -> Result { fn hide(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; + // With every selected object already hidden, the document and its undo history stay as they + // are, and the objects are deselected. + let d = &s.doc()?.doc; + if ids.iter().all(|id| d.node(*id).is_none_or(|n| !n.visible)) { + s.select(|_, sel| sel.clear())?; + return ok(); + } s.edit("Hide", |d, sel| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -544,6 +593,10 @@ fn hide(s: &mut Session, _: &Value) -> Result { fn show_all(s: &mut Session, _: &Value) -> Result { let ids = collect_flag(&s.doc()?.doc, |n| !n.visible); + // With nothing hidden, the document, the selection and the undo history stay as they are. + if ids.is_empty() { + return Ok(json!({ "count": 0 })); + } s.edit("Show All", |d, sel| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -602,6 +655,12 @@ fn compound_make(s: &mut Session, _: &Value) -> Result { fn compound_release(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; + // With no compound path selected, the document, the selection and the undo history stay as + // they are. + let d = &s.doc()?.doc; + if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))) { + return ok(); + } s.edit("Release Compound Path", |d, sel| { let mut out = vec![]; for id in &ids { @@ -678,6 +737,11 @@ fn clip_make(s: &mut Session, _: &Value) -> Result { fn clip_release(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; + // With no clip group selected, the document and its undo history stay as they are. + let d = &s.doc()?.doc; + if !ids.iter().any(|id| d.node(*id).is_some_and(Node::clips)) { + return ok(); + } s.edit("Release Clipping Mask", |d, _| { for id in &ids { release_clip(d, *id); @@ -785,6 +849,27 @@ fn set_props(s: &mut Session, p: &Value) -> Result { ), None => None, }; + // When every object already has every value given, the document and its undo history stay as + // they are. + let flag = |k: &str| p.get(k).and_then(Value::as_bool); + let has_values = |n: &Node| { + str_param(p, "name").is_none_or(|v| n.name.as_deref() == Some(v).filter(|v| !v.is_empty())) + && flag("visible").is_none_or(|v| n.visible == v) + && flag("locked").is_none_or(|v| n.locked == v) + && opacity.is_none_or(|v| n.opacity == v) + && blend.is_none_or(|b| n.blend == b) + && flag("isolate").is_none_or(|v| n.isolate == v) + && knockout.is_none_or(|k| n.knockout == k) + && flag("knockoutShape").is_none_or(|v| n.knockout_shape == v) + && data + .iter() + .flatten() + .all(|(k, v)| n.attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref()) + }; + let d = &s.doc()?.doc; + if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) { + return ok(); + } s.edit("Object Properties", |d, _| { for id in &ids { let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; @@ -864,6 +949,11 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect() } +/// Is every move in `moves` a rounding error (1e-9 pt or less on both axes)? +fn moves_nothing(moves: &[(NodeId, Vec2)]) -> bool { + moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) +} + /// What `object.align` aligns to: `to`, else the key object when there is one, else the /// selection. fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> { @@ -915,6 +1005,10 @@ fn align(s: &mut Session, p: &Value) -> Result { }) .collect() }; + // When nothing moves, the document and its undo history stay as they are. + if moves_nothing(&moves) { + return ok(); + } s.edit("Align", |d, _| { for (id, dv) in &moves { if let Some(n) = d.node_mut(*id) { @@ -965,6 +1059,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result { (*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }) }) .collect(); + // When nothing moves, the document and its undo history stay as they are. + if moves_nothing(&moves) { + return ok(); + } s.edit("Distribute", |d, _| { for (id, dv) in &moves { if let Some(n) = d.node_mut(*id) { @@ -1008,6 +1106,10 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1); let moves: Vec<(NodeId, Vec2)> = deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect(); + // When nothing moves, the document and its undo history stay as they are. + if moves_nothing(&moves) { + return ok(); + } s.edit("Distribute Spacing", |d, _| { for (id, dv) in &moves { if let Some(n) = d.node_mut(*id) { @@ -1057,12 +1159,23 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { let new_rp = scale * gp + (rp - gp) * f(sx, sy); // x, y place the reference point on the page. let page = frame.to_doc() * rp; + // When every value given is the current one, the transform is the identity. Computing it can + // leave a rounding error in it on a rotated object or with Use Preview Bounds. + let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now); + if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) { + return apply_transform(s, "Transform", ids, Affine::IDENTITY, p); + } let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y))); apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p) } fn expand_shape(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; + // With no live shape selected, the document and its undo history stay as they are. + let d = &s.doc()?.doc; + if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { live: Some(_), .. }))) { + return ok(); + } s.edit("Expand Shape", |d, _| { for id in &ids { if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) { @@ -1138,6 +1251,45 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { // A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal. let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?); let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true); + // An ellipse's pie becomes `pie_of(pie)`. An end of 0 is a full turn (Illustrator shows a whole + // ellipse as 0° to 360°). + let end_of = |a: f64| if a == 0.0 { 360.0 } else { a }; + let pie_of = |pie: (f64, f64)| { + let pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of)); + if invert { (pie.1.rem_euclid(360.0), end_of(pie.0)) } else { pie } + }; + // When every value given is the current one, the document and its undo history stay as they + // are. + let st = s.doc()?; + let has_values = |id: &NodeId| { + let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true }; + match live { + Some(l @ LiveShape::Polygon { sides: now, .. }) + if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now)) + && (!equal || l.polygon_sides_equal()) + && polygon_angle.is_none_or(|a| l.polygon_angle() == Some(a)) + && side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius).is_none_or(|r| l.polygon_radius() == Some(r))) => + { + return false; + } + Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false, + _ => {} + } + if radius.is_none() && kind.is_none() { + return true; + } + let Some(c) = LiveCorners::new(path, live.as_ref()) else { return true }; + let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id))); + // Setting corners folds a scale left in a rectangle's transform into its size, which + // changes it. + live.as_ref().is_none_or(|l| l.folded() == *l) + && picked + .iter() + .all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd)) + }; + if ids.iter().all(has_values) { + return ok(); + } s.edit("Live Shape", |d, sel| { for id in &ids { let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; @@ -1163,12 +1315,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { && (pie_start.is_some() || pie_end.is_some() || invert) { if let LiveShape::Ellipse { pie, .. } = l { - // An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°). - let end_of = |a: f64| if a == 0.0 { 360.0 } else { a }; - *pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of)); - if invert { - *pie = (pie.1.rem_euclid(360.0), end_of(pie.0)); - } + *pie = pie_of(*pie); } *path = l.to_path(); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7b107a5f5..e49a8c91d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1324,6 +1324,11 @@ impl Session { { let st = self.doc_mut()?; let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) }; + // Undoing the previous preview counts as a change, so views redraw even when the command + // below returns without an edit. + if !Arc::ptr_eq(&st.doc, &it.doc) || st.selection != it.selection { + st.revision += 1; + } st.doc = it.doc.clone(); st.selection = it.selection.clone(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6aee69d72..4894c7e88 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -330,6 +330,426 @@ fn selection_tool_drag_is_one_undo_step() { assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0); } +/// How many steps there are to undo and to redo, and whether the document has unsaved changes. +fn history(s: &Session) -> (usize, usize, bool) { + let st = s.doc().unwrap(); + (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) +} + +/// Select `ids` with a move of them left to redo, and mark the document saved. +fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.doc_mut().unwrap().mark_saved(); +} + +/// An Align that moves nothing (one object on the artboard's left edge, a pair already aligned, a +/// pair that an earlier Align left a rounding error apart) records no undo step: the step to redo +/// stays, the document stays unmodified, and the journal, which the Actions panel records from, +/// still has the command. +#[test] +fn an_align_that_moves_nothing_records_no_undo_step() { + let mut s = session(); + let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; + let left = json!({"horizontal": "left"}); + let a = rect(&mut s, 0.0, 50.0, 40.0, 40.0); + select_with_a_step_to_redo(&mut s, &[a]); + let before = history(&s); + assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved"); + // Object › Align sends no `to`, and the Align panel aligns one object to the artboard. + for p in [left.clone(), json!({"horizontal": "left", "to": "artboard"})] { + s.execute("object.align", &p).unwrap(); + assert_eq!(history(&s), before, "one object and no key: {p}"); + assert_eq!(s.journal.last().unwrap(), &("object.align".to_string(), p)); + } + let b = rect(&mut s, 0.0, 200.0, 80.0, 20.0); + select_with_a_step_to_redo(&mut s, &[a, b]); + let before = history(&s); + s.execute("object.align", &left).unwrap(); + assert_eq!(history(&s), before, "a pair already aligned"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!((x0(&s, a), x0(&s, b)), (5.0, 5.0), "Redo brings back the move"); + // An Align that moves something is one undo step, and it clears Redo. + let c = rect(&mut s, 120.0, 300.0, 20.0, 20.0); + select_with_a_step_to_redo(&mut s, &[a, c]); + let undo = history(&s).0; + s.execute("object.align", &left).unwrap(); + assert_eq!(history(&s), (undo + 1, 0, true)); + assert_eq!(x0(&s, c), 5.0); + // Aligning a rectangle at 51.8 to one at 10.7 leaves it a rounding error off 10.7, and a + // second Align records no step. + let d = rect(&mut s, 10.7, 400.0, 20.0, 20.0); + let e = rect(&mut s, 51.8, 450.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [d.0, e.0]})).unwrap(); + s.execute("object.align", &left).unwrap(); + assert!(x0(&s, e) != 10.7 && (x0(&s, e) - 10.7).abs() < 1e-12, "{}", x0(&s, e)); + s.doc_mut().unwrap().mark_saved(); + let before = history(&s); + s.execute("object.align", &left).unwrap(); + assert_eq!(history(&s), before, "a pair the first Align aligned"); +} + +/// Distribute and Distribute Spacing over objects already evenly spaced move nothing and record no +/// undo step either. +#[test] +fn a_distribute_that_moves_nothing_records_no_undo_step() { + let mut s = session(); + // The rectangles are 20 pt wide and 30 pt apart. + let ids = [0.0, 50.0, 100.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0)); + select_with_a_step_to_redo(&mut s, &ids); + let before = history(&s); + let calls = [ + ("object.distribute", json!({"horizontal": "left"})), + ("object.distribute", json!({"horizontal": "center"})), + ("object.distribute", json!({"horizontal": "right"})), + ("object.distributeSpacing", json!({"axis": "horizontal"})), + ("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 30})), + ]; + for (id, p) in calls { + s.execute(id, &p).unwrap(); + assert_eq!(history(&s), before, "{id} {p}"); + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p)); + } + // Distribute Spacing keeps the key object still and spaces the others from it. + s.execute("select.key", &json!({"id": ids[1].0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.key, Some(ids[1])); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 30})).unwrap(); + assert_eq!(history(&s), before, "spaced 30 pt from the key object"); + // After the middle one moves 10 pt right, Distribute moves it back in one undo step. + s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap(); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap(); + let undo = history(&s).0; + s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap(); + assert_eq!(history(&s).0, undo + 1); + assert_eq!(s.doc().unwrap().doc.node(ids[1]).unwrap().geometric_bounds().unwrap().x0, 50.0); + // For rectangles 30.1 pt apart, the gap Distribute Spacing computes moves the third one by a + // rounding error. + let ids = [0.1, 50.2, 100.3].map(|x| rect(&mut s, x, 100.0, 20.0, 20.0)); + select_with_a_step_to_redo(&mut s, &ids); + let before = history(&s); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap(); + assert_eq!(history(&s), before, "30.1 pt apart"); + // Distributing these three leaves the middle one a rounding error off halfway between the + // others, and a second Distribute records no step. + let ids = [40.045, 433.482, 1935.587].map(|x| rect(&mut s, x, 200.0, 20.0, 20.0)); + s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap(); + s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = history(&s); + s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap(); + assert_eq!(history(&s), before, "three objects the first Distribute spaced"); +} + +/// Move by 0 pt, Rotate and Shear by 0°, Scale to 100%, an identity Transform, Set Bounds with the +/// current position and size, a Nudge of 0 (of objects, anchors or ruler guides), a Transform with +/// no object to transform and a Selection tool drag that ends where it started record no undo +/// step. Transform Again then repeats the identity, and a Nudge of 0 of a live rectangle's anchor +/// leaves the rectangle a live shape. +#[test] +fn a_transform_that_moves_nothing_records_no_undo_step() { + let mut s = session(); + // The Nudges of a guide and of anchors below use this ruler guide and this plain path. + s.execute("guide.add", &json!({"vertical": true, "pos": 600})).unwrap(); + let made = s.execute("path.create", &json!({"anchors": [{"x": 300, "y": 300}, {"x": 400, "y": 300}]})).unwrap(); + let path = NodeId(made["id"].as_u64().unwrap()); + let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0); + select_with_a_step_to_redo(&mut s, &[a]); + let before = history(&s); + let calls = [ + ("object.move", json!({"dx": 0, "dy": 0})), + ("object.rotate", json!({"angle": 0})), + ("object.scale", json!({"sx": 100})), + ("object.shear", json!({"angle": 0})), + ("object.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})), + ("object.nudge", json!({"dx": 0, "dy": 0})), + ("object.setBounds", json!({})), + ("object.transformAgain", json!({})), + ]; + for (id, p) in calls { + assert_eq!(s.execute(id, &p).unwrap(), json!({"ids": [a.0]}), "{id} {p}"); + assert_eq!(history(&s), before, "{id} {p}"); + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p)); + } + assert_eq!(s.doc().unwrap().last_transform, Some((Affine::IDENTITY, false))); + // A Nudge of 0 with an anchor of the rectangle direct-selected keeps it a live shape. + let is_live = |s: &Session| matches!(s.doc().unwrap().doc.node(a).unwrap().kind, NodeKind::Path { live: Some(_), .. }); + assert!(is_live(&s), "a rectangle is a live shape"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("object.nudge", &json!({"dx": 0, "dy": 0})).unwrap(); + assert!(is_live(&s), "a Nudge of 0 keeps it a live shape"); + assert_eq!(history(&s), before, "an anchor of the live rectangle, then object.nudge"); + // A Nudge of 0 with anchors direct-selected or with only a ruler guide selected, and a + // Transform with nothing selected or with ids that name no object, with or without a copy, + // record no undo step either. + let calls = [ + ("select.anchors", json!({"id": path.0, "anchors": [[0, 0]]}), "object.nudge", json!({"dx": 0, "dy": 0})), + ("guide.select", json!({"indexes": [0]}), "object.nudge", json!({"dx": 0, "dy": 0})), + ("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})), + ("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true})), + ("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999]})), + ("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true})), + ]; + for (select, sp, id, p) in calls { + s.execute(select, &sp).unwrap(); + s.execute(id, &p).unwrap(); + assert_eq!(history(&s), before, "{select} {sp}, then {id} {p}"); + } + // A copy of no object lists no copies and keeps the selection. + let p = json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true}); + assert_eq!(s.execute("object.transform", &p).unwrap(), json!({"ids": []})); + assert_eq!(s.doc().unwrap().selection.objects, [a]); + // A Selection tool drag that ends where it started records no undo step either. Back at the + // start, the revision advances, so views redraw the object where it was. + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let x0 = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0; + let revision = |s: &Session| s.doc().unwrap().revision; + s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 35.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 35.0), v).unwrap(); + assert_ne!(x0(&s), 10.0, "the preview moves it"); + let moved = revision(&s); + s.pointer(&PointerEvent::new(PointerKind::Drag, 30.0, 35.0), v).unwrap(); + assert_eq!(x0(&s), 10.0); + assert!(revision(&s) > moved, "views redraw it back at the start"); + s.pointer(&PointerEvent::new(PointerKind::Up, 30.0, 35.0), v).unwrap(); + assert_eq!(history(&s), before, "a drag back to the start"); + assert_eq!(s.journal.last().unwrap().0, "object.transform"); + // With a copy, a Move by 0 pt duplicates the object in one undo step. + let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len(); + let n = count(&s); + s.execute("object.move", &json!({"dx": 0, "dy": 0, "copy": true})).unwrap(); + assert_eq!(history(&s), (before.0 + 1, 0, true)); + assert_eq!(count(&s), n + 1); +} + +/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and +/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to +/// Current Layer on objects already on top of the current layer. +#[test] +fn an_arrange_that_changes_no_order_records_no_undo_step() { + let mut s = session(); + let ids = [0.0, 30.0, 60.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0)); + let order = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::>(); + let calls = [ + (vec![ids[2]], "object.arrange.bringToFront"), + (vec![ids[2]], "object.arrange.bringForward"), + (vec![ids[0]], "object.arrange.sendToBack"), + (vec![ids[0]], "object.arrange.sendBackward"), + (vec![ids[2]], "object.arrange.sendToCurrentLayer"), + (vec![ids[1], ids[2]], "object.arrange.bringToFront"), + (vec![ids[1], ids[2]], "object.arrange.bringForward"), + (vec![ids[0], ids[1]], "object.arrange.sendToBack"), + (vec![ids[0], ids[1]], "object.arrange.sendBackward"), + (vec![ids[1], ids[2]], "object.arrange.sendToCurrentLayer"), + ]; + for (sel, c) in calls { + select_with_a_step_to_redo(&mut s, &sel); + let before = history(&s); + s.execute(c, &json!({})).unwrap(); + assert_eq!(history(&s), before, "{c} {sel:?}"); + assert_eq!(order(&s), ids, "{c} {sel:?}"); + } + // Bring to Front on the back object is one undo step, and it clears Redo. + select_with_a_step_to_redo(&mut s, &[ids[0]]); + let undo = history(&s).0; + s.execute("object.arrange.bringToFront", &json!({})).unwrap(); + assert_eq!(history(&s), (undo + 1, 0, true)); + assert_eq!(order(&s), [ids[1], ids[2], ids[0]]); +} + +/// Ungroup, Release Compound Path and Release Clipping Mask with none of their objects selected, +/// and Expand Shape with no live shape selected, record no undo step and keep the selection and its +/// key object. +#[test] +fn ungroup_release_and_expand_shape_with_nothing_to_act_on_record_no_undo_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + // Expand Shape makes the two rectangles plain paths in one undo step. + let undo = history(&s).0; + s.execute("object.expandShape", &json!({})).unwrap(); + assert_eq!(history(&s).0, undo + 1); + select_with_a_step_to_redo(&mut s, &[a, b]); + s.execute("select.key", &json!({"id": a.0})).unwrap(); + let (before, selection) = (history(&s), s.doc().unwrap().selection.clone()); + assert_eq!(selection.key, Some(a)); + for c in ["object.ungroup", "object.compoundPath.release", "object.clippingMask.release", "object.expandShape"] { + s.execute(c, &json!({})).unwrap(); + assert_eq!(history(&s), before, "{c}"); + assert_eq!(s.doc().unwrap().selection, selection, "{c}"); + assert_eq!(s.journal.last().unwrap().0, c); + } + // Ungroup with a group selected is one undo step. + s.execute("object.group", &json!({})).unwrap(); + let undo = history(&s).0; + s.execute("object.ungroup", &json!({})).unwrap(); + assert_eq!(history(&s).0, undo + 1); +} + +/// Unlock All with nothing locked and Show All with nothing hidden record no undo step and keep the +/// selection. Lock with every selected object already locked and Hide with every one already +/// hidden record no undo step and deselect them. +#[test] +fn lock_hide_unlock_all_and_show_all_with_nothing_to_do_record_no_undo_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + select_with_a_step_to_redo(&mut s, &[a]); + let before = history(&s); + for c in ["object.unlockAll", "object.showAll"] { + assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 0}), "{c}"); + assert_eq!(history(&s), before, "{c}"); + assert_eq!(s.doc().unwrap().selection.objects, [a], "{c}"); + } + // With the object locked or hidden, each is one undo step that selects it. + for (off, on) in [("object.lock", "object.unlockAll"), ("object.hide", "object.showAll")] { + s.execute(off, &json!({})).unwrap(); + let undo = history(&s).0; + assert_eq!(s.execute(on, &json!({})).unwrap(), json!({"count": 1}), "{on}"); + assert_eq!(history(&s).0, undo + 1, "{on}"); + assert_eq!(s.doc().unwrap().selection.objects, [a], "{on}"); + } + // Object Properties locks (then hides) a new rectangle `b`, and `select.set` selects it. + // Lock (then Hide) deselects it and records no undo step. With `a` selected too, it is one + // undo step. + for (c, props) in [("object.lock", json!({"locked": true})), ("object.hide", json!({"visible": false}))] { + let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0); + s.execute("object.setProps", &props).unwrap(); + select_with_a_step_to_redo(&mut s, &[a]); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let before = history(&s); + s.execute(c, &json!({})).unwrap(); + assert_eq!(history(&s), before, "{c}"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "{c}"); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute(c, &json!({})).unwrap(); + assert_eq!(history(&s), (before.0 + 1, 0, true), "{c} with a"); + s.execute("edit.undo", &json!({})).unwrap(); + } +} + +/// Set Bounds with the current position and size records no undo step on a rotated object, with +/// and without Use Preview Bounds, and on an unrotated object with Use Preview Bounds. In these +/// cases computing the transform can leave a rounding error in it. +#[test] +fn set_bounds_with_the_current_position_and_size_records_no_undo_step() { + let mut s = session(); + let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0); + s.execute("stroke.set", &json!({"weight": 5})).unwrap(); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + let c = rect(&mut s, 0.1, 0.05, 10.1, 6.06); + s.execute("stroke.set", &json!({"weight": 0.3})).unwrap(); + select_with_a_step_to_redo(&mut s, &[a]); + let before = history(&s); + for (id, preview) in [(a, false), (a, true), (c, true)] { + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("prefs.set", &json!({"key": "usePreviewBounds", "value": preview})).unwrap(); + // The Transform panel's values: X and Y place the reference point, W and H are the sides. + let b = s.transform_box(&[id]).unwrap(); + let (rp, w, h) = (b.reference_point(4), b.rect.width(), b.rect.height()); + for p in [json!({}), json!({"x": rp.x, "y": rp.y, "width": w, "height": h}), json!({"width": w, "proportional": true})] { + assert_eq!(s.execute("object.setBounds", &p).unwrap(), json!({"ids": [id.0]}), "{p}"); + assert_eq!(history(&s), before, "{id:?}, Use Preview Bounds {preview}: {p}"); + assert_eq!(s.journal.last().unwrap().0, "object.setBounds"); + } + } + // A new width is one undo step, and it clears Redo. + s.execute("object.setBounds", &json!({"width": 50})).unwrap(); + assert_eq!(history(&s), (before.0 + 1, 0, true)); +} + +/// Object Properties with the values the objects already have records no undo step: every +/// property, data already set or already absent, and no object. A value that differs on one of +/// the objects is one undo step, and an id that names no object is still an error. +#[test] +fn object_properties_already_set_record_no_undo_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0); + let set = json!({"ids": [a.0], "name": "A", "opacity": 50, "blend": "Multiply", "knockout": "on", "data": {"pivot": "1,2"}}); + s.execute("object.setProps", &set).unwrap(); + select_with_a_step_to_redo(&mut s, &[a, b]); + let before = history(&s); + let defaults = json!({ + "id": b.0, "name": "", "visible": true, "locked": false, "opacity": 100, "blend": "Normal", "isolate": false, + "knockout": "neutral", "knockoutShape": false, "data": {"pivot": null} + }); + let calls = + [set, defaults, json!({"id": a.0, "data": {"data-pivot": "1,2", "other": null}}), json!({"visible": true}), json!({}), json!({"ids": []})]; + for p in calls { + s.execute("object.setProps", &p).unwrap(); + assert_eq!(history(&s), before, "{p}"); + assert_eq!(s.journal.last().unwrap(), &("object.setProps".to_string(), p)); + } + // `a` already has 50% opacity, and `b` does not. + s.execute("object.setProps", &json!({"opacity": 50})).unwrap(); + assert_eq!(history(&s), (before.0 + 1, 0, true)); + assert!(s.execute("object.setProps", &json!({"ids": [999999], "visible": true})).is_err()); +} + +/// Live Shape Properties that a shape already has record no undo step: a polygon's sides, angle, +/// radius, side length and equal sides, an ellipse's pie, Invert Pie on a whole ellipse, and the +/// radius and kind its corners have (given corners, the Direct-Selected one, or all). A value that +/// differs is one undo step. Setting the radius of a rectangle that kept a scale in its transform +/// is one undo step too, because it folds the scale into the rectangle's size. +#[test] +fn live_shape_properties_already_set_record_no_undo_step() { + let mut s = session(); + let id = |v: Value| NodeId(v["id"].as_u64().unwrap()); + let r = rect(&mut s, 0.0, 0.0, 100.0, 60.0); + let polygon = id(s.execute("shape.polygon", &json!({"cx": 300, "cy": 100, "radius": 50, "sides": 6})).unwrap()); + let ellipse = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 80, "height": 40})).unwrap()); + let star = id(s.execute("shape.star", &json!({"cx": 300, "cy": 300, "radius1": 60, "radius2": 30})).unwrap()); + // The rectangle's corners get 8 pt radii, its top-right corner gets 12 pt, and the polygon gets + // 30 pt sides. + for p in [json!({"id": r.0, "radius": 8}), json!({"id": r.0, "corners": [1], "radius": 12}), json!({"id": polygon.0, "sideLength": 30})] { + s.execute("object.setLiveShape", &p).unwrap(); + } + let NodeKind::Path { live: Some(l), .. } = &s.doc().unwrap().doc.node(polygon).unwrap().kind else { panic!("not a live polygon") }; + let (angle, radius) = (l.polygon_angle().unwrap(), l.polygon_radius().unwrap()); + select_with_a_step_to_redo(&mut s, &[r, polygon, ellipse, star]); + let before = history(&s); + let calls = [ + json!({"id": polygon.0, "sides": 6, "polygonAngle": angle, "polygonRadius": radius, "makeSidesEqual": true}), + json!({"id": polygon.0, "sideLength": 30, "kind": "round"}), + json!({"id": ellipse.0, "pieStart": 0, "pieEnd": 360}), + json!({"id": ellipse.0, "invertPie": true}), + json!({"id": r.0, "corners": [0, 2, 3], "radius": 8, "kind": "round"}), + json!({"items": [{"id": r.0, "corners": [1]}], "radius": 12}), + json!({"id": star.0, "radius": 0}), + ]; + for p in calls { + s.execute("object.setLiveShape", &p).unwrap(); + assert_eq!(history(&s), before, "{p}"); + assert_eq!(s.journal.last().unwrap(), &("object.setLiveShape".to_string(), p)); + } + // With an anchor of the top-left corner direct-selected, the radius applies to that corner. + s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("object.setLiveShape", &json!({"radius": 8})).unwrap(); + assert_eq!(history(&s), before, "the Direct-Selected corner"); + for p in [json!({"id": polygon.0, "sides": 7}), json!({"id": ellipse.0, "pieEnd": 90}), json!({"radius": 9})] { + let undo = history(&s).0; + s.execute("object.setLiveShape", &p).unwrap(); + assert_eq!(history(&s).0, undo + 1, "{p}"); + } + // The rectangle is now 50 × 60 pt with 10 pt corners, stretched to 100 × 60 pt by its + // transform, as files from before #291 saved it. Setting the radius its corners show makes the + // corners circles. + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") }; + let xf = Affine::scale_non_uniform(2.0, 1.0); + *live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + *path = live.to_path(); + } + let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0); + let undo = history(&s).0; + s.execute("object.setLiveShape", &json!({"id": r.0, "radius": shown})).unwrap(); + assert_eq!(history(&s).0, undo + 1, "a stretched rectangle"); +} + #[test] fn rectangle_tool_draws() { let mut s = session(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 3cd5716f9..3908fa7a6 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -624,7 +624,8 @@ mod tests { out.textures_delta.clear(); }; let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); - let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + // The undo steps and the journal entries (the dialog's OK runs its command, which the journal records). + let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len()); app.select_tool("selection"); frame(&mut app, vec![enter()]); assert_eq!(kind(&app), None, "nothing is selected"); @@ -645,9 +646,11 @@ mod tests { frame(&mut app, vec![]); assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: and it stays open"); assert_eq!(steps(&app), before, "{tool}: the Enter that opened it didn't confirm it"); - // The next Enter is its OK. + // The next Enter is its OK. With its default values only Reflect changes the art, so only + // Reflect records an undo step. frame(&mut app, vec![enter()]); - assert_eq!((kind(&app), steps(&app)), (None, before + 1), "{tool}: Enter confirms"); + let after = (before.0 + usize::from(dialog == "reflect"), before.1 + 1); + assert_eq!((kind(&app), steps(&app)), (None, after), "{tool}: Enter confirms"); } app.select_tool("rectangle"); frame(&mut app, vec![enter()]); diff --git a/docs/mcp.md b/docs/mcp.md index d74f20839..92ac77bed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -284,7 +284,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | -| `undo` / `redo` | `{}` | | +| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. | With `mods.shift`, a marquee dragged with the Selection tool toggles the objects it reaches (the selected ones leave the selection, the others join it, the rest stays), and one dragged with Direct or Group Selection toggles the anchors From f043a1a392d9ca32f7e5bec0c750dbcfab153176 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 06:58:57 +0700 Subject: [PATCH 78/97] #1032: spot colours defined in Lab keep their Lab values A .ai or PDF whose spot colours are defined in Lab opened them wrong: - the PDF part gives such an ink an ICC Lab profile as its alternate space ([/ICCBased profile], 3 components), which was read as RGB, so the Lab values were clamped into wildly wrong colours (PANTONE 249 C came in yellow). A profile whose Alternate is [/Lab ...], or whose header names the Lab data colour space, is now read as Lab; - the editing data writes them as `c m y k L a b (name) tint 2 Xx`, type 2 being Lab, and only the CMYK equivalent was read. Type 2 now gives the Lab colour, in the swatches and on the art. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/eps/src/import/ai/paint.rs | 29 ++++++++++++++++------ crates/pdf/src/import_color.rs | 37 +++++++++++++++++++++++----- crates/pdf/src/tests_import_color.rs | 30 ++++++++++++++++++++++ docs/file-format-parity.md | 5 ++-- 5 files changed, 87 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 490733ef8..ea0669c59 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index af6f86e22..783e7e448 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -2,10 +2,10 @@ //! //! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint //! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK -//! values its equivalent), `Xx`/`XX` a named colour of either model -//! (`c m y k [r g b] (name) tint type`), `Xk`/`XK` a global process colour (its operands as -//! `Xx`'s: a global swatch that isn't an ink of its own). Lower case paints fills, upper case -//! strokes. +//! values its equivalent), `Xx`/`XX` a named colour of any model +//! (`c m y k [r g b | L a b] (name) tint type`, type 0 CMYK, 1 RGB, 2 Lab), `Xk`/`XK` a global +//! process colour (its operands as `Xx`'s: a global swatch that isn't an ink of its own). Lower +//! case paints fills, upper case strokes. //! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6] //! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`. //! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d @@ -57,8 +57,8 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { } } -/// `c m y k (name) tint x` or `c m y k [r g b] (name) tint type Xx` (type 1: RGB); `rgb` forces -/// the model. +/// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab); +/// `rgb` forces RGB or CMYK. fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?; let name = vals.get(at)?.text()?; @@ -67,9 +67,14 @@ fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { let unit = |v: f32| v.clamp(0.0, 1.0); let tint = 1.0 - unit(*after.first()?); let n = before.len(); - let color = if rgb.unwrap_or(after.get(1).is_some_and(|t| *t == 1.0)) { + let kind = after.get(1).copied(); + let color = if rgb.unwrap_or(kind == Some(1.0)) { let [r, g, b] = before.get(n.checked_sub(3)?..)? else { return None }; Color::rgb(unit(*r), unit(*g), unit(*b)) + } else if rgb.is_none() && kind == Some(2.0) { + // Type 2: a colour defined in Lab, its L a b values after the CMYK equivalent (#1032). + let [l, a, b] = before.get(n.checked_sub(3)?..)? else { return None }; + Color::lab(l.clamp(0.0, 100.0), a.clamp(-128.0, 127.0), b.clamp(-128.0, 127.0)) } else { // The CMYK values come first (an RGB equivalent may follow them). let from = if n >= 7 { n - 7 } else { n.checked_sub(4)? }; @@ -266,6 +271,16 @@ mod tests { v.extend([V::Str(b"Orange".to_vec()), V::Num(0.0), V::Num(1.0)]); let (p, _) = color_op("Xk", &v).unwrap(); assert_eq!(p.color(), Some(Color::rgb(1.0, 0.5, 0.0))); + // A spot colour defined in Lab (type 2): its L a b values, not its CMYK equivalent (#1032). + let mut v = nums(&[0.52, 0.98, 0.28, 0.1, 32.9412, 45.0, -18.0]); + v.extend([V::Str(b"Plum Ink".to_vec()), V::Num(0.0), V::Num(2.0)]); + let (p, named) = color_op("Xx", &v).unwrap(); + let named = named.unwrap(); + assert_eq!((named.color, named.spot, named.tint), (Color::lab(32.9412, 45.0, -18.0), true, 1.0)); + assert_eq!(p.color(), Some(Color::lab(32.9412, 45.0, -18.0))); + v[8] = V::Num(0.25); + let (_, named) = color_op("XX", &v).unwrap(); + assert_eq!(named.map(|n| (n.color, n.tint)), Some((Color::lab(32.9412, 45.0, -18.0), 0.75))); } #[test] diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs index c4966fbe8..f38eadb1b 100644 --- a/crates/pdf/src/import_color.rs +++ b/crates/pdf/src/import_color.rs @@ -389,12 +389,16 @@ fn alt_model(o: &Object<'_>) -> Option { Object::Array(a) => { let mut it = a.flex_iter(); match it.next::>()?.as_str() { - "ICCBased" => match it.next::>()?.dict().get::(b"N")? { - 1 => Some(Alt::Gray), - 3 => Some(Alt::Rgb), - 4 => Some(Alt::Cmyk), - _ => None, - }, + "ICCBased" => { + let profile = it.next::>()?; + match profile.dict().get::(b"N")? { + 1 => Some(Alt::Gray), + 3 if lab_profile(&profile) => Some(Alt::Lab), + 3 => Some(Alt::Rgb), + 4 => Some(Alt::Cmyk), + _ => None, + } + } "Lab" => Some(Alt::Lab), "CalRGB" => Some(Alt::Rgb), "CalGray" => Some(Alt::Gray), @@ -406,6 +410,27 @@ fn alt_model(o: &Object<'_>) -> Option { } } +/// Is the ICC profile of an `ICCBased` space a Lab one (its alternate space `Lab`, else its header's +/// data colour space `Lab `)? Spot colours defined in Lab come with such a profile as their +/// alternate space (#1032), and their tint transforms give Lab values. +fn lab_profile(profile: &Stream<'_>) -> bool { + use std::io::Read; + if let Some(Object::Array(a)) = profile.dict().get::>(b"Alternate") { + return a.flex_iter().next::>().is_some_and(|n| n.as_str() == "Lab"); + } + // The header's first 20 bytes, without decoding the rest of the profile. + let raw = profile.raw_data(); + let mut head = vec![]; + match profile.filters().as_slice() { + [] => head.extend(raw.iter().take(20)), + [hayro_syntax::Filter::FlateDecode] => { + let _ = flate2::read::ZlibDecoder::new(&*raw).take(20).read_to_end(&mut head); // a damaged profile reads as not Lab + } + _ => return false, + } + head.get(16..20) == Some(b"Lab ") +} + /// An alternate-space colour (the tint transform's output) as a VectorCraft colour. fn alt_color(alt: Alt, v: &[f32]) -> Option { let c = |i: usize| v.get(i).copied().filter(|x| x.is_finite()); diff --git a/crates/pdf/src/tests_import_color.rs b/crates/pdf/src/tests_import_color.rs index 4a0acc4de..d9d690ce2 100644 --- a/crates/pdf/src/tests_import_color.rs +++ b/crates/pdf/src/tests_import_color.rs @@ -215,3 +215,33 @@ fn colour_spaces_are_read_from_the_interpreters_debug_form() { assert_eq!((space, comps), ("ColorSpace(DeviceCmyk)", vec![0.101960786, 0.2, 0.3, 0.4])); assert_eq!(crate::import_color::split_color("Color { color_space: X, components: [NaN], opacity: 1.0 }"), None); } + +/// Spot colours defined in Lab can have an ICC Lab profile as their alternate space, `[/ICCBased +/// profile]` with 3 components: the tint transform gives Lab values, which are not RGB (#1032). The +/// profile is told by its `Alternate` (`[/Lab …]`), else by its header's data colour space. +#[test] +fn spot_colours_with_an_icc_lab_alternate_keep_their_lab_values() { + let f = first_extra(1); + // A profile's first 20 bytes: size, CMM, version, class, then the data colour space. + let profile = |head: &str, alternate: &str| format!("<< /N 3 {alternate} /Length {} >>\nstream\n{head}\nendstream", head.len()); + let lab_header = profile("0000none0000spacLab XYZ ", ""); + let lab_alternate = profile("0000none0000spacXYZ XYZ ", "/Alternate [/Lab << /WhitePoint [0.9642 1 0.8249] /Range [-128 127 -128 127] >>]"); + let rgb = profile("0000none0000mntrRGB XYZ ", ""); + let tint = |c1: &str| format!("<< /FunctionType 2 /Domain [0 1] /C0 [100 0 0] /C1 [{c1}] /N 1 >>"); + let spaces = format!( + "/CS0 [/Separation /Plum [/ICCBased {f} 0 R] {}] /CS1 [/Separation /Sky [/ICCBased {} 0 R] {}] /CS2 [/Separation /Leafy [/ICCBased {} 0 R] << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0.5 0.9 0.1] /N 1 >>]", + tint("32.9412 45 -18"), + f + 1, + tint("51 -26 -45"), + f + 2 + ); + let page = page_with(&spaces, &[("CS0", "1"), ("CS1", "0.5"), ("CS2", "1")]); + let d = import(&pdf_with(&[page], &[&lab_header, &lab_alternate, &rgb], None)).unwrap(); + let f = fills(&d); + assert_eq!(solid(&f[0]).1, Some("Plum")); + assert!(near(d.swatch("Plum").unwrap().paint.color().unwrap(), Color::lab(32.941, 45.0, -18.0)), "{:?}", d.swatch("Plum")); + assert_eq!((solid(&f[1]).1, solid(&f[1]).2), (Some("Sky"), 0.5)); + assert!(near(d.swatch("Sky").unwrap().paint.color().unwrap(), Color::lab(51.0, -26.0, -45.0)), "{:?}", d.swatch("Sky")); + // An RGB profile stays RGB. + assert!(near(d.swatch("Leafy").unwrap().paint.color().unwrap(), Color::rgb(0.5, 0.9, 0.1)), "{:?}", d.swatch("Leafy")); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6830f7fe5..2adb0684b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, from the `FORMATS` table and Illustrator 2026's documented formats) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Lab spot colours in `.ai` and PDF, #1032) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public @@ -44,7 +44,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format @@ -80,6 +80,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Spot colours defined in Lab keep their Lab values, from the editing data and from a PDF part whose ink alternate is an ICC Lab profile (#1032) | | 2026-10-11 | minor | `.ai` in PostScript form opens through its layers, also without its prolog (#1027) | | 2026-10-11 | minor | `.ai` editing data: global process colours and pattern fills read, so they no longer drop sublayers (#1025) | | 2026-10-10 | major | First checklist; moved the `.ai` editing-data scope from the ROADMAP's "Out of scope" here; added the user-reported `.ai` failures | From c912ef59d15c8591c9a1bc7bd31d3d88cae9899d Mon Sep 17 00:00:00 2001 From: Michael Gieson Date: Sat, 10 Oct 2026 17:38:13 -0400 Subject: [PATCH 79/97] Build: generated sources are rewritten only when they change crates/text/build.rs (craft_fonts.rs) and crates/ui-egui/build.rs (credits.rs) wrote their output on every run. Where cargo's recorded times lose their sub-second part (an ecryptfs home directory, as Mint and Ubuntu offer at install), a file written just before its crate compiles reads as newer than that compile, so the next build compiled the crate and everything above it a second time: a full app rebuild for nothing after any build-script rerun. Unchanged output is now left alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/build.rs | 6 ++++++ crates/ui-egui/build.rs | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/text/build.rs b/crates/text/build.rs index 73c952faf..25544a3a6 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -51,6 +51,12 @@ fn main() { } let src = format!("pub static CRAFT_FONTS: &[CraftFont] = &[\n{fonts}];\npub static WEB_FONTS: &[WebFont] = &[\n{web}];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); + // Unchanged output keeps its mtime. Rewritten just before this crate compiles, it can read as + // newer than that compile where cargo's recorded times lose their sub-second part (ecryptfs + // home directories), and the next build would compile this crate and everything above it again. + if std::fs::read_to_string(&out).is_ok_and(|old| old == src) { + return; + } if let Err(e) = std::fs::write(&out, src) { println!("cargo::error=writing {}: {e}", out.display()); } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f40007fb2..22fa00b26 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -21,7 +21,13 @@ fn main() { let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::().map_err(|e| e.to_string())) .unwrap_or_default(); let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs"); - if let Err(e) = std::fs::write(&out, generate(&json, &people)) { + let src = generate(&json, &people); + // Unchanged output keeps its mtime, so the next build doesn't compile this crate again + // (see crates/text/build.rs). + if std::fs::read_to_string(&out).is_ok_and(|old| old == src) { + return; + } + if let Err(e) = std::fs::write(&out, src) { println!("cargo::warning=writing {}: {e}", out.display()); } } From 1217d58ca1c6bf9fdff193d78add6d1a0369a2a9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:44:02 +0200 Subject: [PATCH 80/97] Gradient mesh: a fill colour recolours the point last clicked (#1050) --- crates/engine/src/cmd/paint.rs | 22 ++++++++++++++++- crates/engine/src/tests_editcolors.rs | 34 +++++++++++++++++++++++++++ crates/tools/src/direct.rs | 6 ++++- crates/tools/src/meshblend.rs | 15 ++++++++++-- crates/tools/src/meshedit.rs | 11 +++++++-- 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 236112249..2bc62004b 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only", has_doc, |s, p| set_paint(s, p, true) ), @@ -407,6 +407,9 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result Result Result Option<(NodeId, usize)> { + let o = s.tool_options(); + let pt = o.get("meshPoint")?; + Some((NodeId(pt.get("id")?.as_u64()?), usize::try_from(pt.get("index")?.as_u64()?).ok()?)) +} + /// A type run's stroke; a painted stroke on a run without one gets 1 pt. fn set_run_stroke(style: &mut CharStyle, paint: Paint) { if !paint.is_none() { diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests_editcolors.rs index c3413d120..93ef8faad 100644 --- a/crates/engine/src/tests_editcolors.rs +++ b/crates/engine/src/tests_editcolors.rs @@ -3,8 +3,10 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::Node; +use vectorcraft_tools::{PointerEvent, PointerKind}; use super::*; +use crate::tooling::ViewInfo; fn session() -> Session { let mut s = Session::new(); @@ -99,6 +101,38 @@ fn edit_colors_reach_mesh_points() { assert_eq!(mesh_colors(&s, m), pts, "a mesh's points are fill colours"); } +/// Clicking a gradient mesh's point with the Mesh tool or Direct Selection picks it: a solid fill +/// colour then recolours that point alone (#1050). +#[test] +fn a_clicked_mesh_point_takes_the_fill_colour() { + let mut s = session(); + let m = mesh(&mut s); + let before = mesh_colors(&s, m); + let v = ViewInfo::default(); + let click = |s: &mut Session, i: usize| { + let NodeKind::Mesh(g) = &s.doc().unwrap().doc.node(m).unwrap().kind else { panic!("not a mesh") }; + let q = g.points[i].p; + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, q.x, q.y), v).unwrap(); + } + }; + // The Mesh tool selects the mesh whose point it clicks. + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("mesh", v).unwrap(); + click(&mut s, 1); + assert_eq!(s.doc().unwrap().selection.objects, [m]); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + let after = mesh_colors(&s, m); + assert_eq!(after[1].to_hex(), "#0000ff", "the clicked point takes the colour"); + assert_eq!((after[0], after[2], after[3]), (before[0], before[2], before[3]), "the other points keep theirs"); + s.select_tool("directSelection", v).unwrap(); + click(&mut s, 2); + s.execute("paint.setFill", &json!({"color": "#ffff00"})).unwrap(); + let after = mesh_colors(&s, m); + assert_eq!((after[1].to_hex(), after[2].to_hex()), ("#0000ff".to_string(), "#ffff00".to_string())); + assert_eq!((after[0], after[3]), (before[0], before[3])); +} + #[test] fn blend_moves_a_mesh_to_the_blended_colour() { let mut s = session(); diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index a8c490b95..739423b27 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -15,7 +15,8 @@ //! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]). //! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves //! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes -//! and mesh envelopes and their handles ([`MeshEdit`]). Dragging a corner or an edge of area +//! and mesh envelopes and their handles ([`MeshEdit`]; a solid fill colour recolours a gradient +//! mesh's point last clicked). Dragging a corner or an edge of area //! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size. //! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]). //! @@ -291,6 +292,9 @@ impl Tool for DirectSelectionTool { fn busy(&self) -> bool { !matches!(self.state, State::Idle) || self.guide.busy() } + fn options(&self) -> Value { + self.mesh.options() + } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { if let Some(out) = self.guide.pointer(cx, ev) { return out; diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 5958b5509..e5b747daf 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -8,7 +8,8 @@ //! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's //! new point takes the current fill colour unless Shift); drag a mesh point, or a handle of the //! point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); Alt-click a mesh point → -//! `object.mesh.deletePoint`. +//! `object.mesh.deletePoint`. Clicking a gradient mesh's point selects the mesh, and the next +//! solid fill colour recolours just that point. use serde_json::json; use vectorcraft_color::Paint; @@ -183,7 +184,14 @@ impl Tool for MeshTool { self.edit.unfocus(); return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": g.id.0, "index": g.index}))]; } - return self.edit.press(g); + // A gradient mesh's point is picked for the next fill colour, so its mesh is + // what gets selected. + let mut out = vec![]; + if cx.selection.objects != [g.id] && cx.doc.node(g.id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_))) { + out.push(Action::Exec("select.set".into(), json!({"ids": [g.id.0]}))); + } + out.extend(self.edit.press(g)); + return out; } self.edit.unfocus(); let Some((top, leaf)) = hit_top(cx, p) else { return vec![] }; @@ -236,6 +244,9 @@ impl Tool for MeshTool { None => Cursor::PenAdd, } } + fn options(&self) -> serde_json::Value { + self.edit.options() + } fn busy(&self) -> bool { self.edit.dragging() } diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs index 3a6a53feb..1af0998cf 100644 --- a/crates/tools/src/meshedit.rs +++ b/crates/tools/src/meshedit.rs @@ -1,10 +1,11 @@ //! Dragging mesh points and their handles on the canvas, shared by the Mesh tool and Direct //! Selection: gradient meshes and mesh envelopes alike (`object.mesh.movePoint`). Clicking a -//! point focuses it: its handles show and can be dragged too. +//! point focuses it: its handles show and can be dragged too, and on a gradient mesh a solid fill +//! colour recolours it (the tool's `meshPoint` option). use std::borrow::Cow; -use serde_json::json; +use serde_json::{Value, json}; use vectorcraft_doc::live::{GradientMesh, envelope_grid}; use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind}; use vectorcraft_geom::Point; @@ -91,6 +92,12 @@ impl MeshEdit { self.focus = None; } + /// `{meshPoint: {id, index}|null}`: the focused point, which a solid fill colour recolours + /// (`paint.setFill` on a gradient mesh). + pub fn options(&self) -> Value { + json!({"meshPoint": self.focus.map(|(id, index)| json!({"id": id.0, "index": index}))}) + } + /// The focused point's handles (lines and ends). pub fn overlays(&self, cx: &ToolContext) -> Vec { let Some((id, index)) = self.focus else { return vec![] }; From a785b53e7b237eb941307bebfd1631fe7f4df994 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 06:13:04 +0700 Subject: [PATCH 81/97] ROADMAP: generated sources in the progress log (#1051) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index ea0669c59..1fec65d6c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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 | From 63026402a135324654250819f665ab4547e3ac39 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:48:55 +0700 Subject: [PATCH 82/97] fix(ui): keep All Tools drawer compact on wide screens (#1018) --- crates/ui-egui/src/dialogs/all_tools.rs | 35 ++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/all_tools.rs b/crates/ui-egui/src/dialogs/all_tools.rs index 402e6f444..b5ce58253 100644 --- a/crates/ui-egui/src/dialogs/all_tools.rs +++ b/crates/ui-egui/src/dialogs/all_tools.rs @@ -10,7 +10,16 @@ use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, icons, widgets}; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: 560.0, ..DialogSpec::FORM }; +/// Keep the drawer to two tool cells instead of stretching across a wide window. +/// The shared dialog frame will shrink further on narrow screens. +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("All Tools").into(), + body, + ok: None, + min_width: 376.0, + max_width: Some(376.0), + ..DialogSpec::FORM +}; /// A tool's cell: its icon and name. const CELL: egui::Vec2 = vec2(176.0, 28.0); @@ -95,6 +104,30 @@ mod tests { use super::*; /// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order. + + + /// #1018: the All Tools drawer must not fill a desktop-sized window. + #[test] + fn all_tools_dialog_has_a_bounded_width() { + use egui::{Pos2, RawInput, Rect}; + + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.ui.dialog = Some(Dialog::new("allTools", serde_json::json!({}))); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + for width in [1280.0, 430.0] { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 900.0)); + for _ in 0..6 { + let mut out = ctx.run_ui(RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + } + let rect = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "allTools")))).expect("drawer is shown"); + assert!(rect.width() <= width, "drawer exceeds viewport: {rect:?}"); + assert!(rect.width() <= 465.0, "drawer expanded beyond two tool columns: {rect:?}"); + } + } + #[test] fn every_tool_is_listed_once_under_a_heading() { let s = sections(); From ec41d72e0df7d469be772871b83493f037f798e8 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:50:27 +0700 Subject: [PATCH 83/97] fix(textwrap): reject malformed explicit object targets atomically --- crates/engine/src/cmd/textwrap.rs | 43 ++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs index 6da8b4539..6e9333a32 100644 --- a/crates/engine/src/cmd/textwrap.rs +++ b/crates/engine/src/cmd/textwrap.rs @@ -36,9 +36,11 @@ pub fn specs() -> Vec { ] } -fn targets(s: &Session, p: &Value) -> Result> { - match p.get("ids").and_then(Value::as_array) { - Some(a) => Ok(a.iter().filter_map(Value::as_u64).map(NodeId).collect()), +/// An explicit target list must be all valid ids: a partially applied text wrap is +/// particularly surprising when some of the chosen objects are silently skipped. +fn targets(s: &Session, p: &Value, command: &str) -> Result> { + match p.get("ids") { + Some(_) => checked_ids_param(s, p, "ids", command), None => selected_roots(s), } } @@ -48,7 +50,7 @@ fn wrap_param(p: &Value, base: TextWrap) -> TextWrap { } fn make(s: &mut Session, p: &Value) -> Result { - let ids = targets(s, p)?; + let ids = targets(s, p, "object.textWrap.make")?; let w = wrap_param(p, TextWrap::default()); s.edit("Make Text Wrap", |d, _| { for id in &ids { @@ -66,7 +68,7 @@ fn make(s: &mut Session, p: &Value) -> Result { fn release(s: &mut Session, p: &Value) -> Result { let ids: Vec = { let d = &s.doc()?.doc; - targets(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect() + targets(s, p, "object.textWrap.release")?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect() }; if ids.is_empty() { return Err(EngineError::Other("Text Wrap: select a wrap object".into())); @@ -83,7 +85,7 @@ fn release(s: &mut Session, p: &Value) -> Result { } fn options(s: &mut Session, p: &Value) -> Result { - let ids = targets(s, p)?; + let ids = targets(s, p, "object.textWrap.options")?; let current = { let d = &s.doc()?.doc; ids.iter().find_map(|i| d.node(*i).and_then(|n| n.wrap)).unwrap_or_default() @@ -212,6 +214,35 @@ mod tests { vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).lines.iter().map(|l| l.avail).collect() } + /// Invalid explicit ids must not turn a requested multi-object edit into a partial one. + #[test] + fn text_wrap_commands_reject_malformed_target_lists_without_editing() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap(); + let other = s.execute("shape.rectangle", &json!({"x": 120, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + + let before = s.doc().unwrap().history.undo.len(); + for command in ["object.textWrap.make", "object.textWrap.release", "object.textWrap.options"] { + for ids in [json!([id, "invalid"]), json!([id, 99999]), json!({"not": "an array"})] { + let e = s.execute(command, &json!({"ids": ids, "offset": 12})).unwrap_err().to_string(); + assert!(e.contains("id") || e.contains("array"), "{command}: {e}"); + assert_eq!(s.doc().unwrap().history.undo.len(), before, "{command} added history on error"); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none()); + } + } + + s.execute("object.textWrap.make", &json!({"ids": [id]})).unwrap(); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_some()); + assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none()); + s.execute("object.textWrap.options", &json!({"ids": [id], "offset": 12})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.unwrap().offset, 12.0); + s.execute("object.textWrap.release", &json!({"ids": [id]})).unwrap(); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none()); + } + #[test] fn area_type_wraps_around_objects_above_and_follows_edits() { let mut s = Session::new(); From 50dbe4c10ea0d35bbff4be23d0b6efbb51f33be5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 06:13:43 +0700 Subject: [PATCH 84/97] ROADMAP: recolouring mesh points in the progress log (#1052) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1fec65d6c..d61414f09 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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 | From c3e0ca3dfaa76bc6ab8662ff8445bdd001eafafd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 06:14:54 +0700 Subject: [PATCH 85/97] Review: All Tools width from its cells; test order; ROADMAP (#1054) The drawer width is worked out from the tool cell, and the new test no longer sits between the existing test and its doc comment. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/dialogs/all_tools.rs | 46 +++++++++++-------------- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d61414f09..4034d6125 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/ui-egui/src/dialogs/all_tools.rs b/crates/ui-egui/src/dialogs/all_tools.rs index b5ce58253..30584b5a3 100644 --- a/crates/ui-egui/src/dialogs/all_tools.rs +++ b/crates/ui-egui/src/dialogs/all_tools.rs @@ -10,20 +10,16 @@ use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, icons, widgets}; -/// Keep the drawer to two tool cells instead of stretching across a wide window. -/// The shared dialog frame will shrink further on narrow screens. -pub(super) const SPEC: DialogSpec = DialogSpec { - heading: |_| tl!("All Tools").into(), - body, - ok: None, - min_width: 376.0, - max_width: Some(376.0), - ..DialogSpec::FORM -}; +/// A compact drawer two tools wide, however wide the window (#1018); the dialog frame narrows it +/// further in a narrow window. +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: WIDTH, max_width: Some(WIDTH), ..DialogSpec::FORM }; /// A tool's cell: its icon and name. const CELL: egui::Vec2 = vec2(176.0, 28.0); const ICON: f32 = 20.0; +/// The drawer's width: two cells, the gap between them and room for the scroll bar. +const WIDTH: f32 = 2.0 * CELL.x + 24.0; /// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the /// Advanced toolbar shows. @@ -104,9 +100,20 @@ mod tests { use super::*; /// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order. + #[test] + fn every_tool_is_listed_once_under_a_heading() { + let s = sections(); + let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect(); + let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect(); + assert_eq!(listed.len(), all.len()); + assert!(all.iter().all(|id| listed.contains(id))); + let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect(); + assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]); + assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}"); + assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}"); + } - - /// #1018: the All Tools drawer must not fill a desktop-sized window. + /// The drawer stays two tools wide in a wide window and fits a narrow one (#1018). #[test] fn all_tools_dialog_has_a_bounded_width() { use egui::{Pos2, RawInput, Rect}; @@ -124,20 +131,7 @@ mod tests { } let rect = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "allTools")))).expect("drawer is shown"); assert!(rect.width() <= width, "drawer exceeds viewport: {rect:?}"); - assert!(rect.width() <= 465.0, "drawer expanded beyond two tool columns: {rect:?}"); + assert!(rect.width() < 3.0 * CELL.x, "drawer wider than two tools: {rect:?}"); } } - - #[test] - fn every_tool_is_listed_once_under_a_heading() { - let s = sections(); - let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect(); - let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect(); - assert_eq!(listed.len(), all.len()); - assert!(all.iter().all(|id| listed.contains(id))); - let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect(); - assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]); - assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}"); - assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}"); - } } From f5111045a94e34cc331a74edd2c9ccf61a797ea9 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:50:49 +0700 Subject: [PATCH 86/97] fix(gradient): reject malformed freeform line index lists --- crates/engine/src/cmd/freeform.rs | 43 ++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 21fd5c92d..7d984a0f9 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -148,9 +148,19 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result point_fields(pv, &mut pt, Affine::IDENTITY).map_err(|e| format!("freeform point {i}: {e}"))?; f.points.push(pt); } - for l in v.get("lines").and_then(Value::as_array).into_iter().flatten() { - let ix = l.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default(); - f.add_line(ix)?; + if let Some(lines) = v.get("lines") { + let lines = lines.as_array().ok_or("`freeform.lines` must be an array")?; + for (line, values) in lines.iter().enumerate() { + let values = values.as_array().ok_or_else(|| format!("freeform line {line} must be an array of point indices"))?; + let indices: Vec = values + .iter() + .map(|v| { + let id = v.as_u64().ok_or_else(|| format!("freeform line {line} has a non-integer point index: {v}"))?; + usize::try_from(id).map_err(|_| format!("freeform line {line} has an out-of-range point index: {id}")) + }) + .collect::>()?; + f.add_line(indices).map_err(|e| format!("freeform line {line}: {e}"))?; + } } if let Some(m) = str_param(v, "mode") { f.mode = parse_mode(m)?; @@ -323,3 +333,30 @@ fn get(s: &mut Session, p: &Value) -> Result { v["selected"] = json!(selected); Ok(v) } + + +#[cfg(test)] +mod parse_tests { + use super::*; + + #[test] + fn malformed_freeform_lines_are_not_silently_shortened() { + let points = json!([ + {"at": [0, 0], "color": "#ff0000"}, + {"at": [50, 0], "color": "#00ff00"}, + {"at": [100, 0], "color": "#0000ff"} + ]); + for lines in [ + json!([[0, "broken", 2]]), + json!([[0, -1, 2]]), + json!([[0, 1.5, 2]]), + json!([{"points": [0, 1]}]), + json!("not an array"), + ] { + let value = json!({"points": points, "lines": lines, "mode": "lines"}); + assert!(parse_freeform(&value).is_err(), "invalid lines accepted: {value}"); + } + let good = parse_freeform(&json!({"points": points, "lines": [[0, 1, 2]], "mode": "lines"})).unwrap(); + assert_eq!(good.lines, vec![vec![0, 1, 2]]); + } +} From 212fb9994aa5a815e55bc1f06825b22ec131fe3b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 06:15:42 +0700 Subject: [PATCH 87/97] Review: one checked ids-or-selection helper; ROADMAP (#1055) Text Wrap and the variable bindings share checked_ids_or_selection in cmd/mod.rs instead of each keeping its own copy. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/mod.rs | 5 +++++ crates/engine/src/cmd/textwrap.rs | 16 +++------------- crates/engine/src/cmd/variablecmds.rs | 9 ++------- 4 files changed, 11 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4034d6125..812ad548a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/mod.rs b/crates/engine/src/cmd/mod.rs index a3d142ebd..49be3c08a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -316,6 +316,11 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str values.iter().map(|value| checked_id(s, value, command)).collect() } +/// The objects a command acts on: `ids` when given (every one checked), else the selected objects. +pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result> { + if p.get("ids").is_some() { checked_ids_param(s, p, "ids", command) } else { edit::selected_roots(s) } +} + pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result { let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?; if s.doc()?.doc.node(id).is_none() { diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs index 6e9333a32..e916f65ce 100644 --- a/crates/engine/src/cmd/textwrap.rs +++ b/crates/engine/src/cmd/textwrap.rs @@ -8,7 +8,6 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape}; use vectorcraft_geom::{PathData, shapes}; -use super::edit::selected_roots; use super::typecmd::refresh_bounds; use super::*; @@ -36,21 +35,12 @@ pub fn specs() -> Vec { ] } -/// An explicit target list must be all valid ids: a partially applied text wrap is -/// particularly surprising when some of the chosen objects are silently skipped. -fn targets(s: &Session, p: &Value, command: &str) -> Result> { - match p.get("ids") { - Some(_) => checked_ids_param(s, p, "ids", command), - None => selected_roots(s), - } -} - fn wrap_param(p: &Value, base: TextWrap) -> TextWrap { TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) } } fn make(s: &mut Session, p: &Value) -> Result { - let ids = targets(s, p, "object.textWrap.make")?; + let ids = checked_ids_or_selection(s, p, "object.textWrap.make")?; let w = wrap_param(p, TextWrap::default()); s.edit("Make Text Wrap", |d, _| { for id in &ids { @@ -68,7 +58,7 @@ fn make(s: &mut Session, p: &Value) -> Result { fn release(s: &mut Session, p: &Value) -> Result { let ids: Vec = { let d = &s.doc()?.doc; - targets(s, p, "object.textWrap.release")?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect() + checked_ids_or_selection(s, p, "object.textWrap.release")?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect() }; if ids.is_empty() { return Err(EngineError::Other("Text Wrap: select a wrap object".into())); @@ -85,7 +75,7 @@ fn release(s: &mut Session, p: &Value) -> Result { } fn options(s: &mut Session, p: &Value) -> Result { - let ids = targets(s, p, "object.textWrap.options")?; + let ids = checked_ids_or_selection(s, p, "object.textWrap.options")?; let current = { let d = &s.doc()?.doc; ids.iter().find_map(|i| d.node(*i).and_then(|n| n.wrap)).unwrap_or_default() diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index a47551702..79b746670 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -49,11 +49,6 @@ fn names_param(p: &Value, cmd: &str, fallback: Option, nothing: &str) -> } } -/// The objects a bind acts on: `ids` when given (every one checked), else the selection. -fn target_ids(s: &Session, p: &Value, cmd: &str) -> Result> { - if p.get("ids").is_some() { checked_ids_param(s, p, "ids", cmd) } else { selected_roots(s) } -} - /// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked /// layer is not the document's to rewrite, and a text variable only drives type. fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> { @@ -203,7 +198,7 @@ fn list(s: &mut Session, _p: &Value) -> Result { fn bind(s: &mut Session, p: &Value) -> Result { const C: &str = "variable.bind"; let name = named(str_param(p, "variable"), "variable", C)?; - let ids = target_ids(s, p, C)?; + let ids = checked_ids_or_selection(s, p, C)?; if ids.is_empty() { return Err(bad(C, "nothing to bind")); } @@ -218,7 +213,7 @@ fn bind(s: &mut Session, p: &Value) -> Result { fn unbind(s: &mut Session, p: &Value) -> Result { const C: &str = "variable.unbind"; - let ids = target_ids(s, p, C)?; + let ids = checked_ids_or_selection(s, p, C)?; let name = defined_variable(s, p, C)?; let n = s.edit("Unbind Variable", |d, _| { let mut n = 0; From 935a588aa2ff704a841858fd5aee1220de7f5a4b Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:51:58 +0700 Subject: [PATCH 88/97] fix(corners): reject malformed explicit dialog targets --- crates/ui-egui/src/dialogs/corners.rs | 37 ++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/corners.rs b/crates/ui-egui/src/dialogs/corners.rs index 6924ee166..4cc820d85 100644 --- a/crates/ui-egui/src/dialogs/corners.rs +++ b/crates/ui-egui/src/dialogs/corners.rs @@ -27,8 +27,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Corners").int /// `corners` (default: the selected corners), filled in with their kind and radius. pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let st = app.session.active().ok_or("no document")?; - let id = match p.get("id").and_then(Value::as_u64) { - Some(id) => NodeId(id), + let id = match p.get("id") { + Some(value) => NodeId(value.as_u64().ok_or("`id` must be a non-negative integer object id")?), None => match st.selection.objects[..] { [id] => id, _ => return Err("select one path, or give its `id`".into()), @@ -37,8 +37,18 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let Some(live) = st.doc.node(id).and_then(LiveCorners::of) else { return Err("Corners edits the corners of a path".into()); }; - let corners: BTreeSet = match p.get("corners").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_u64).filter_map(|k| usize::try_from(k).ok()).filter(|k| live.corner(*k).is_some()).collect(), + let corners: BTreeSet = match p.get("corners") { + Some(Value::Array(values)) => values + .iter() + .map(|value| { + let index = value.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| format!("invalid corner index {value}"))?; + if live.corner(index).is_none() { + return Err(format!("anchor {index} is not a corner of this path")); + } + Ok(index) + }) + .collect::>()?, + Some(_) => return Err("`corners` must be an array of corner indices".into()), None => live.picked(st.selection.partial(id)), }; if corners.is_empty() { @@ -149,6 +159,25 @@ mod tests { v } + /// Explicit UI-command parameters cannot silently fall back to the selection or drop corners. + #[test] + fn malformed_explicit_corner_targets_never_open_a_partial_edit() { + let (mut app, id) = app(); + for params in [ + json!({"id": "invalid"}), + json!({"id": -1}), + json!({"id": id, "corners": [0, "bad", 2]}), + json!({"id": id, "corners": [0, 1.5]}), + json!({"id": id, "corners": [0, 9]}), + json!({"id": id, "corners": "bad"}), + ] { + assert!(app.run("ui.corners", params.clone()).is_err(), "accepted {params}"); + assert!(app.ui.dialog.is_none(), "opened a partial edit for {params}"); + } + app.run("ui.corners", json!({"id": id, "corners": [0, 2]})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["corners"], json!([0, 2])); + } + #[test] fn double_clicking_a_direct_selected_corner_edits_it_alone() { let (mut app, id) = app(); From adfe6e3820c46a1ca51b1ab7d06d0af9d0a4db10 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 06:17:40 +0700 Subject: [PATCH 89/97] Review: one point-index parser for freeform lines; i18n; ROADMAP (#1056) parse_freeform and paint.freeform.addLine share point_indices, which also keeps addLine from wrapping a huge index round on 32-bit targets (wasm). The new messages are in the catalogs that translate every message. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/freeform.rs | 37 +++++++++++-------------------- crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ 9 files changed, 28 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 812ad548a..f4819a429 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/freeform.rs b/crates/engine/src/cmd/freeform.rs index 7d984a0f9..531e9bf99 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -150,16 +150,9 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result } if let Some(lines) = v.get("lines") { let lines = lines.as_array().ok_or("`freeform.lines` must be an array")?; - for (line, values) in lines.iter().enumerate() { - let values = values.as_array().ok_or_else(|| format!("freeform line {line} must be an array of point indices"))?; - let indices: Vec = values - .iter() - .map(|v| { - let id = v.as_u64().ok_or_else(|| format!("freeform line {line} has a non-integer point index: {v}"))?; - usize::try_from(id).map_err(|_| format!("freeform line {line} has an out-of-range point index: {id}")) - }) - .collect::>()?; - f.add_line(indices).map_err(|e| format!("freeform line {line}: {e}"))?; + for (i, l) in lines.iter().enumerate() { + let ix = point_indices(l).ok_or_else(|| format!("freeform line {i} must be an array of point indices"))?; + f.add_line(ix).map_err(|e| format!("freeform line {i}: {e}"))?; } } if let Some(m) = str_param(v, "mode") { @@ -168,6 +161,12 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result Ok(f) } +/// `v` as a list of point indices: `None` unless it is an array of non-negative integers, so no +/// malformed index is dropped or wrapped round. +fn point_indices(v: &Value) -> Option> { + v.as_array()?.iter().map(|i| usize::try_from(i.as_u64()?).ok()).collect() +} + pub(crate) fn parse_mode(m: &str) -> std::result::Result { FreeformMode::parse(m).ok_or_else(|| format!("unknown freeform mode `{m}` (points, lines)")) } @@ -289,11 +288,7 @@ fn delete_point(s: &mut Session, p: &Value) -> Result { fn add_line(s: &mut Session, p: &Value) -> Result { const C: &str = "paint.freeform.addLine"; - let points: Vec = p - .get("points") - .and_then(Value::as_array) - .and_then(|a| a.iter().map(|v| v.as_u64().map(|i| i as usize)).collect()) - .ok_or_else(|| bad(C, "`points` must be an array of point indices"))?; + let points = p.get("points").and_then(point_indices).ok_or_else(|| bad(C, "`points` must be an array of point indices"))?; let line = edit(s, p, C, |f, _| f.add_line(points.clone()))?; Ok(json!({ "line": line })) } @@ -334,11 +329,11 @@ fn get(s: &mut Session, p: &Value) -> Result { Ok(v) } - #[cfg(test)] -mod parse_tests { +mod tests { use super::*; + /// A line with an index that isn't a point's is refused, not shortened to the indices that are. #[test] fn malformed_freeform_lines_are_not_silently_shortened() { let points = json!([ @@ -346,13 +341,7 @@ mod parse_tests { {"at": [50, 0], "color": "#00ff00"}, {"at": [100, 0], "color": "#0000ff"} ]); - for lines in [ - json!([[0, "broken", 2]]), - json!([[0, -1, 2]]), - json!([[0, 1.5, 2]]), - json!([{"points": [0, 1]}]), - json!("not an array"), - ] { + for lines in [json!([[0, "broken", 2]]), json!([[0, -1, 2]]), json!([[0, 1.5, 2]]), json!([{"points": [0, 1]}]), json!("not an array")] { let value = json!({"points": points, "lines": lines, "mode": "lines"}); assert!(parse_freeform(&value).is_err(), "invalid lines accepted: {value}"); } diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8599f203f..a8dfc8c1e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3321,6 +3321,7 @@ theme Appearance Erscheinungsbild @msg `color` must be a colour `color` muss eine Farbe sein @msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` muss ein Befehl sein, dessen Menüeintrag ein Dialogfeld öffnet (siehe ui.menuDialog) @msg `freeform.points` must be an array `freeform.points` muss ein Array sein +@msg `freeform.lines` must be an array `freeform.lines` muss ein Array sein @msg `gradient` must be an object `gradient` muss ein Objekt sein @msg `library` is a library id or name (see {list}) `library` ist die ID oder der Name einer Bibliothek (siehe {list}) @msg `mode` applies to freeform gradients `mode` gilt für Freiformverläufe @@ -3422,6 +3423,7 @@ theme Appearance Erscheinungsbild @msg empty path leerer Pfad @msg file version {0} is newer than this VectorCraft supports ({VERSION}) die Dateiversion {0} ist neuer, als dieses VectorCraft unterstützt ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` Freiformpunkt {i} braucht `at` [x, y] und eine gültige `color` +@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 the path `id` and the width point `index` geben Sie die `id` des Pfads und den `index` des Breitenpunkts an diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e1dc04199..8013cc932 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3422,6 +3422,7 @@ axis Both Ambos @msg `color` must be a colour `color` debe ser un color @msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` debe ser un comando cuyo elemento de menú abra un cuadro de diálogo (consulta ui.menuDialog) @msg `freeform.points` must be an array `freeform.points` debe ser una lista +@msg `freeform.lines` must be an array `freeform.lines` debe ser una lista @msg `gradient` must be an object `gradient` debe ser un objeto @msg `library` is a library id or name (see {list}) `library` es el id o el nombre de una biblioteca (consulta {list}) @msg `mode` applies to freeform gradients `mode` se aplica a los degradados de forma libre @@ -3523,6 +3524,7 @@ axis Both Ambos @msg empty path trazado vacío @msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versión de archivo {0} es más reciente de lo que admite este VectorCraft ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` el punto de forma libre {i} necesita `at` [x, y] y un `color` válido +@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 the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 41a1a44d5..82131bb26 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3419,6 +3419,7 @@ axis Both Les deux @msg `color` must be a colour `color` doit être une couleur @msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` doit être une commande dont l’élément de menu ouvre une boîte de dialogue (voir ui.menuDialog) @msg `freeform.points` must be an array `freeform.points` doit être un tableau +@msg `freeform.lines` must be an array `freeform.lines` doit être un tableau @msg `gradient` must be an object `gradient` doit être un objet @msg `library` is a library id or name (see {list}) `library` est l’identifiant ou le nom d’une bibliothèque (voir {list}) @msg `mode` applies to freeform gradients `mode` s’applique aux dégradés de forme libre @@ -3520,6 +3521,7 @@ axis Both Les deux @msg empty path tracé vide @msg file version {0} is newer than this VectorCraft supports ({VERSION}) la version de fichier {0} est plus récente que celles que prend en charge cette version de VectorCraft ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` le point de forme libre {i} nécessite `at` [x, y] et une `color` valide +@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 the path `id` and the width point `index` indiquez l’`id` du tracé et l’`index` du point de largeur diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 12eded13d..735c7dc72 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3422,6 +3422,7 @@ axis Both Entrambi @msg `color` must be a colour `color` deve essere un colore @msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` deve essere un comando la cui voce di menu apre una finestra di dialogo (vedi ui.menuDialog) @msg `freeform.points` must be an array `freeform.points` deve essere un array +@msg `freeform.lines` must be an array `freeform.lines` deve essere un array @msg `gradient` must be an object `gradient` deve essere un oggetto @msg `library` is a library id or name (see {list}) `library` è l'id o il nome di una libreria (vedi {list}) @msg `mode` applies to freeform gradients `mode` si applica alle sfumature a forma libera @@ -3523,6 +3524,7 @@ axis Both Entrambi @msg empty path tracciato vuoto @msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versione del file {0} è più recente di quelle supportate da questo VectorCraft ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` il punto a forma libera {i} richiede `at` [x, y] e un `color` valido +@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 the path `id` and the width point `index` specifica l'`id` del tracciato e l'`index` del punto di larghezza diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8baf8dc60..604c9b399 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3471,9 +3471,11 @@ theme Appearance 外観 @msg `at` must be [x, y] `at` は [x, y] の形式で指定してください @msg `color` must be a colour `color` にはカラーを指定してください @msg `freeform.points` must be an array `freeform.points` には配列を指定してください +@msg `freeform.lines` must be an array `freeform.lines` には配列を指定してください @msg `{key}` must be a number `{key}` には数値を指定してください @msg a freeform gradient keeps at least one point フリーフォームグラデーションには少なくとも 1 つのポイントが必要です @msg freeform point {i} needs `at` [x, y] and a valid `color` フリーフォームのポイント {i} には `at` [x, y] と有効な `color` が必要です +@msg freeform line {i} must be an array of point indices フリーフォームのライン {i} にはポイントのインデックスの配列を指定してください @msg no point {i} (the gradient has {_1}) ポイント {i} はありません (このグラデーションのポイントは {_1} 個です) @msg no segment {segment} on line {line} ライン {line} にセグメント {segment} はありません @msg unknown freeform mode `{m}` (points, lines) `{m}` は不明なフリーフォームのモードです (points、lines) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f218c5c3f..db2d298a7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3418,6 +3418,7 @@ axis Both Оба @msg `color` must be a colour `color` должно быть цветом @msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` должно быть командой, пункт меню которой открывает диалоговое окно (см. ui.menuDialog) @msg `freeform.points` must be an array `freeform.points` должно быть массивом +@msg `freeform.lines` must be an array `freeform.lines` должно быть массивом @msg `gradient` must be an object `gradient` должно быть объектом @msg `library` is a library id or name (see {list}) `library` — это идентификатор или имя библиотеки (см. {list}) @msg `mode` applies to freeform gradients `mode` применяется к произвольным градиентам @@ -3519,6 +3520,7 @@ axis Both Оба @msg empty path пустой контур @msg file version {0} is newer than this VectorCraft supports ({VERSION}) версия файла {0} новее, чем поддерживает этот VectorCraft ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` произвольной точке {i} нужны `at` [x, y] и допустимый `color` +@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 the path `id` and the width point `index` укажите `id` контура и `index` точки ширины diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e01e389c2..ff5269324 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3405,6 +3405,7 @@ axis Both Обидві @msg `color` must be a colour `color` має бути кольором @msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` має бути командою, пункт меню якої відкриває діалогове вікно (див. ui.menuDialog) @msg `freeform.points` must be an array `freeform.points` має бути масивом +@msg `freeform.lines` must be an array `freeform.lines` має бути масивом @msg `gradient` must be an object `gradient` має бути об’єктом @msg `library` is a library id or name (see {list}) `library` — це ідентифікатор або ім’я бібліотеки (див. {list}) @msg `mode` applies to freeform gradients `mode` застосовується до довільних градієнтів @@ -3506,6 +3507,7 @@ axis Both Обидві @msg empty path порожній контур @msg file version {0} is newer than this VectorCraft supports ({VERSION}) версія файлу {0} новіша, ніж підтримує цей VectorCraft ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` довільній точці {i} потрібні `at` [x, y] та допустимий `color` +@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 the path `id` and the width point `index` вкажіть `id` контуру та `index` точки ширини From fffec4a714e02078df291e9e0eabae6025b6bd8d Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:52:22 +0700 Subject: [PATCH 90/97] fix(views): validate saved-view coordinates and rotation --- crates/engine/src/cmd/views.rs | 38 +++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/views.rs b/crates/engine/src/cmd/views.rs index 65c55f93e..9ec778360 100644 --- a/crates/engine/src/cmd/views.rs +++ b/crates/engine/src/cmd/views.rs @@ -55,9 +55,18 @@ fn list_views(s: &mut Session, _: &Value) -> Result { fn new_view(s: &mut Session, p: &Value) -> Result { const C: &str = "view.saved.new"; - let center = p.get("center").and_then(Value::as_array).and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); - let center = center.ok_or_else(|| bad(C, "missing center [x, y]"))?; + let center = match p.get("center").and_then(Value::as_array).map(Vec::as_slice) { + Some([x, y]) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Point::new(x, y), + _ => return Err(bad(C, "center must contain two finite numbers")), + }, + _ => return Err(bad(C, "center must be [x, y]")), + }; let zoom = f64_req(p, "zoom", C)?; + let rotation = match p.get("rotation") { + Some(value) => value.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(C, "rotation must be a finite number"))?, + None => 0.0, + }; if !(zoom.is_finite() && zoom > 0.0) { return Err(bad(C, "zoom must be positive")); } @@ -72,7 +81,7 @@ fn new_view(s: &mut Session, p: &Value) -> Result { if st.doc.views.iter().any(|v| v.name == name) { return Err(bad(C, format!("a view named `{name}` exists"))); } - let view = SavedView { name: name.clone(), center, zoom, rotation: f64_or(p, "rotation", 0.0) }; + let view = SavedView { name: name.clone(), center, zoom, rotation }; let index = s.edit("New View", |d, _| { d.views.push(view); Ok(d.views.len() - 1) @@ -108,6 +117,29 @@ mod tests { use crate::Session; + /// Malformed dimensions are rejected rather than saving an arbitrary or partial viewport. + #[test] + fn saved_views_require_exact_finite_coordinates_and_rotation() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let before = s.doc().unwrap().history.undo.len(); + for params in [ + json!({"center": [10], "zoom": 2}), + json!({"center": [10, 20, 30], "zoom": 2}), + json!({"center": [10, "bad"], "zoom": 2}), + json!({"center": [10, 20], "zoom": 2, "rotation": "bad"}), + json!({"center": [10, 20], "zoom": 2, "rotation": null}), + ] { + assert!(s.execute("view.saved.new", ¶ms).is_err(), "accepted {params}"); + assert!(s.doc().unwrap().doc.views.is_empty()); + assert_eq!(s.doc().unwrap().history.undo.len(), before); + } + s.execute("view.saved.new", &json!({"center": [10, 20], "zoom": 2, "rotation": -15})).unwrap(); + let views = &s.doc().unwrap().doc.views; + assert_eq!(views.len(), 1); + assert_eq!(views[0].rotation, -15.0); + } + #[test] fn save_rename_delete_views() { let mut s = Session::new(); From 9ea392e6ee87f10f695047182c5acec99e6bf8bb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 9 Oct 2026 20:11:40 -0700 Subject: [PATCH 91/97] Tests: the command sweeps no longer skip junk scale, spacing and artboard values The sweeps `every_command_with_junk_params` and `fuzzed_calls_keep_documents_serializable` skipped every case that set `object.scale`'s `sx`, `object.transformEach`'s `scaleH` or `scaleV`, `object.distributeSpacing`'s `spacing`, or `artboard.setProps`'s `x`, `y`, `width` or `height`. The comment above `KNOWN_BUGS`, the list of those (command, param) pairs, referred to `#[ignore]`d repro tests, but `bug_huge_transform_makes_document_unloadable` and `bug_artboard_huge_props_break_svg` have no `#[ignore]` and run with the other tests. Since 42df20ca, the commit that added the list, `Session::edit` returns an error when an edit leaves an artboard or a selected object with coordinates that are not finite or exceed `MAX_COORD`, and `object.transformEach` clamps `scaleH` and `scaleV`. The list and its two checks are removed, so both sweeps run those cases. --- crates/engine/tests/command_sweep.rs | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index d603ca885..c383051f7 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -81,23 +81,6 @@ fn every_command_without_a_document() { assert!(failures.is_empty(), "{}", failures.join("\n")); } -/// Known bugs (see the `#[ignore]` repro tests below): (command, param key) pairs skipped by the -/// junk sweep so the rest of the sweep stays meaningful. -const KNOWN_BUGS: &[(&str, &str)] = &[ - ("object.scale", "sx"), - ("object.transformEach", "scaleH"), - ("object.transformEach", "scaleV"), - ("object.distributeSpacing", "spacing"), - ("artboard.setProps", "x"), - ("artboard.setProps", "y"), - ("artboard.setProps", "width"), - ("artboard.setProps", "height"), -]; - -fn known_bug(id: &str, p: &Value) -> bool { - KNOWN_BUGS.iter().any(|(c, k)| *c == id && p.get(*k).is_some()) -} - /// Regression: huge column counts used to panic with `capacity overflow`; in every layout and /// order (#681), with huge and negative spacing too. #[test] @@ -157,9 +140,6 @@ fn every_command_with_junk_params() { cases.truncate(12); } for p in &cases { - if known_bug(c.id, p) { - continue; - } calls += 1; if let Some(f) = probe(fx, c.id, p) { failures.push(f); @@ -494,9 +474,6 @@ fn fuzzed_calls_keep_documents_serializable() { let k = if k == "path" { continue } else { k }; let mut s = Fixture::Multi.session(); let p = json!({ k.clone(): v }); - if known_bug(c.id, &p) { - continue; - } let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false); if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) { failures.push(format!("{} {p}: {e}", c.id)); From 39099dcc571ffc6cfecebf38bd999fad541e1311 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 06:18:56 +0700 Subject: [PATCH 92/97] Review: Corners reuses its messages; i18n; ROADMAP (#1057) A malformed id gets the existing object-id message, and a listed anchor that is not a corner the existing "give corners of the path" one, so only the malformed-list message is new (translated in every catalog that covers messages). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/dialogs/corners.rs | 20 +++++++------------- crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + 9 files changed, 15 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f4819a429..4c14bb882 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/ui-egui/src/dialogs/corners.rs b/crates/ui-egui/src/dialogs/corners.rs index 4cc820d85..62bb6523a 100644 --- a/crates/ui-egui/src/dialogs/corners.rs +++ b/crates/ui-egui/src/dialogs/corners.rs @@ -28,7 +28,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Corners").int pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let st = app.session.active().ok_or("no document")?; let id = match p.get("id") { - Some(value) => NodeId(value.as_u64().ok_or("`id` must be a non-negative integer object id")?), + Some(v) => NodeId(v.as_u64().ok_or_else(|| format!("object ids are non-negative integers, not {v}"))?), None => match st.selection.objects[..] { [id] => id, _ => return Err("select one path, or give its `id`".into()), @@ -38,20 +38,14 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { return Err("Corners edits the corners of a path".into()); }; let corners: BTreeSet = match p.get("corners") { - Some(Value::Array(values)) => values - .iter() - .map(|value| { - let index = value.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| format!("invalid corner index {value}"))?; - if live.corner(index).is_none() { - return Err(format!("anchor {index} is not a corner of this path")); - } - Ok(index) - }) - .collect::>()?, - Some(_) => return Err("`corners` must be an array of corner indices".into()), + Some(v) => v + .as_array() + .and_then(|a| a.iter().map(|k| usize::try_from(k.as_u64()?).ok()).collect()) + .ok_or_else(|| format!("`corners` must be an array of anchor indices, not {v}"))?, None => live.picked(st.selection.partial(id)), }; - if corners.is_empty() { + // Every corner given is one of the path's: none is left out of the edit. + if corners.is_empty() || !corners.iter().all(|k| live.corner(*k).is_some()) { return Err("give corners of the path: anchors between two straight sides".into()); } let (radius, kind) = live.style(&corners); diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a8dfc8c1e..d4e97fdb4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3854,6 +3854,7 @@ theme Appearance Erscheinungsbild ICF Bottom/Left ICF unten/links @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` ist der Schrittverweis {r}, den nur `run` und `command_batch` auflösen @msg `ids` must be an array of object ids, not {v} `ids` muss ein Array von Objekt-IDs sein, nicht {v} +@msg `corners` must be an array of anchor indices, not {v} `corners` muss ein Array von Ankerpunkt-Indizes sein, nicht {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: Das Ergebnis von Schritt {i} hat keinen solchen Wert (schreiben Sie $$ für ein literales $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: Vor diesem Schritt gibt es keinen Schritt {i} (Schritte zählen ab 0; schreiben Sie $$ für ein literales $) @msg object ids are non-negative integers, not {v} Objekt-IDs sind nicht negative ganze Zahlen, nicht {v} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8013cc932..407905486 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3941,6 +3941,7 @@ axis Both Ambos 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 `corners` must be an array of anchor indices, not {v} `corners` debe ser una lista de índices de puntos de ancla, 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} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 82131bb26..9a7373ae1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3952,6 +3952,7 @@ axis Both Les deux 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 `corners` must be an array of anchor indices, not {v} `corners` doit être une liste d’indices de points d’ancrage, 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} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 735c7dc72..d8c9a6b1f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3941,6 +3941,7 @@ axis Both Entrambi 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 `corners` must be an array of anchor indices, not {v} `corners` deve essere un elenco di indici di punti di ancoraggio, 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} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 604c9b399..9d691732e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3934,6 +3934,7 @@ theme Appearance 外観 @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 `corners` must be an array of anchor indices, not {v} `corners` はアンカーポイントのインデックスの配列にしてください ({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} は使えません) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index db2d298a7..95591e31a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3939,6 +3939,7 @@ axis Both Оба 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 `corners` must be an array of anchor indices, not {v} `corners` должен быть массивом индексов опорных точек, а не {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} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ff5269324..3410095a2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3924,6 +3924,7 @@ axis Both Обидві 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 `corners` must be an array of anchor indices, not {v} `corners` має бути масивом індексів опорних точок, а не {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} From 68241453dd0d9378a5dfb8d93269c90f782c66d2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 06:19:46 +0700 Subject: [PATCH 93/97] Review: New View reads its centre with finite_numbers; ROADMAP (#1058) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/views.rs | 19 +++++++------------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4c14bb882..956efade7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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/views.rs b/crates/engine/src/cmd/views.rs index 9ec778360..af1693dfd 100644 --- a/crates/engine/src/cmd/views.rs +++ b/crates/engine/src/cmd/views.rs @@ -55,21 +55,16 @@ fn list_views(s: &mut Session, _: &Value) -> Result { fn new_view(s: &mut Session, p: &Value) -> Result { const C: &str = "view.saved.new"; - let center = match p.get("center").and_then(Value::as_array).map(Vec::as_slice) { - Some([x, y]) => match (x.as_f64(), y.as_f64()) { - (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Point::new(x, y), - _ => return Err(bad(C, "center must contain two finite numbers")), - }, - _ => return Err(bad(C, "center must be [x, y]")), - }; + let [x, y] = p.get("center").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "center must be [x, y]"))?; let zoom = f64_req(p, "zoom", C)?; - let rotation = match p.get("rotation") { - Some(value) => value.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(C, "rotation must be a finite number"))?, - None => 0.0, - }; if !(zoom.is_finite() && zoom > 0.0) { return Err(bad(C, "zoom must be positive")); } + // Given, the rotation is a number: nothing malformed falls back to 0°. + let rotation = match p.get("rotation") { + Some(r) => r.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(C, "rotation must be a number of degrees"))?, + None => 0.0, + }; let st = s.doc()?; if st.doc.views.len() >= MAX_VIEWS { return Err(bad(C, format!("a document keeps at most {MAX_VIEWS} views"))); @@ -81,7 +76,7 @@ fn new_view(s: &mut Session, p: &Value) -> Result { if st.doc.views.iter().any(|v| v.name == name) { return Err(bad(C, format!("a view named `{name}` exists"))); } - let view = SavedView { name: name.clone(), center, zoom, rotation }; + let view = SavedView { name: name.clone(), center: Point::new(x, y), zoom, rotation }; let index = s.edit("New View", |d, _| { d.views.push(view); Ok(d.views.len() - 1) From 2d95ac1694eec5ca6ccec6a2d22d3acfadc35cd9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 10 Oct 2026 12:39:14 -0700 Subject: [PATCH 94/97] EPS: arc and arcn with huge angles no longer hang the import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit arc raised its second angle by 2π in a loop until it was no longer below the first, and arcn lowered it the same way. For an angle of 1e18 degrees that loop takes about 3e15 steps: vectorcraft-cli info was still reading an EPS file with "newpath 100 100 50 1e18 0 arc stroke" when "timeout 20" stopped it. For 1e308 the loop never ends: from 2^56 radians (about 4.1e18 degrees) up, adding 2π leaves a double unchanged. The import fuzz test eps_hostile_programs_never_panic builds programs from tokens that include arc, arcn, 1e308 and 1e15. arc now raises the second angle by multiples of 360 until it is at least the first, and arcn lowers it until it is at most the first, as the PostScript Language Reference defines them. The code works out both sweeps from the remainders of the two angles after whole turns, and it places the first endpoint at the remainder of the first angle. Ordinary angles give the same arcs as before, apart from rounding in the last digits. One case changes: when raising (arc) or lowering (arcn) the second angle by whole turns makes it equal to the first, as in 60 -300 arc or -60 300 arcn, rounding sometimes made the old loop add another turn and draw a full circle. Those arcs are now empty, as the PostScript Language Reference defines them. Tests: 1e18 0 arc, 1e308 0 arc, 0 1e18 arcn and 0 1e308 arcn draw the arc between the angles' remainders and leave the current point at the second angle. The test reads each program on a thread of its own and fails when no result arrives within 20 seconds. --- crates/eps/src/import/graphics.rs | 29 +++++++++++++++---------- crates/eps/src/import/tests.rs | 36 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 255da6963..1bbf83379 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -339,6 +339,12 @@ fn turn(a: f64, b: f64) -> f64 { if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d } } +/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left +/// of each after whole turns. +fn ccw_turn(a: f64, b: f64) -> f64 { + (b % 360.0 - a % 360.0).rem_euclid(360.0) +} + impl Interp<'_> { /// User space → document space. pub fn xf(&self) -> Affine { @@ -436,18 +442,19 @@ impl Interp<'_> { /// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise). fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res { - let (a1, mut a2) = (a1.to_radians(), a2.to_radians()); - let tau = std::f64::consts::TAU; - if ccw { - while a2 < a1 { - a2 += tau; - } + // `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it + // until it is at most `a1` (PLRM 3rd ed., `arc` and `arcn` in chapter 8). Those sweeps, + // and the first endpoint, are worked out from what is left of each angle after whole turns. + let sweep = if ccw && a2 < a1 { + ccw_turn(a1, a2) + } else if !ccw && a2 > a1 { + -ccw_turn(a2, a1) } else { - while a2 > a1 { - a2 -= tau; - } - } - let sweep = (a2 - a1).clamp(-tau * 4.0, tau * 4.0); + a2 - a1 + }; + let tau = std::f64::consts::TAU; + let sweep = sweep.to_radians().clamp(-tau * 4.0, tau * 4.0); + let a1 = (a1 % 360.0).to_radians(); let start = c + Vec2::new(r * a1.cos(), r * a1.sin()); self.append_user(std::iter::once(PathEl::MoveTo(start)))?; let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0); diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 356d9dc94..3c44e1a6c 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -640,6 +640,42 @@ fn hostile_executable_files_and_type_loops_are_refused() { blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor"); } +/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes +/// more than 20 s. +fn read_soon(body: &str) -> crate::Imported { + let (tx, rx) = std::sync::mpsc::channel(); + let ps = eps(body); + std::thread::spawn(move || tx.send(import(&ps)).ok()); + rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap() +} + +/// `arc` raises its second angle by multiples of 360 until it is at least the first, and `arcn` +/// lowers it until it is at most the first (PLRM 3rd ed., `arc` and `arcn` in chapter 8), however +/// large the angles: the arc runs between the angles' remainders after whole turns and ends at +/// the second angle, the new current point. +#[test] +fn arcs_with_huge_angles_end_at_their_second_angle() { + // `at(deg)` is the point at `deg` degrees on the circle of radius 10 around (50, 50), in + // document space. + let at = |deg: f64| Point::new(50.0 + 10.0 * deg.to_radians().cos(), 50.0 - 10.0 * deg.to_radians().sin()); + // Each program is listed with the remainders of its first and second angles after whole turns. + for (body, first, second) in [ + ("50 50 10 1e18 0 arc", 280.0, 0.0), + ("50 50 10 1e308 0 arc", 296.0, 0.0), + ("50 50 10 0 1e18 arcn", 0.0, 280.0), + ("50 50 10 0 1e308 arcn", 0.0, 296.0), + ] { + let r = read_soon(&format!("newpath {body} currentpoint stroke 1 1 rectfill")); + let o = objects(&r.document); + assert_eq!(o.len(), 2, "{body}: {:?}", r.warnings); + // The arc lies within one quarter of the circle, so its endpoints span its bounds. + let (a, b) = (at(first), at(second)); + assert!(near(bounds(&o[0]), Rect::from_points(a, b)), "{body}: {:?}", bounds(&o[0])); + // The second object is the square drawn at the current point. + assert!(near(bounds(&o[1]), Rect::new(b.x, b.y - 1.0, b.x + 1.0, b.y)), "{body}: {:?}", bounds(&o[1])); + } +} + #[test] fn cshow_runs_its_procedure_for_each_character() { check("/n 0 def { pop pop pop /n n 1 add def } (abc) cshow n 3 eq"); From b19731b307234a01aef52062146f96d05e88b272 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 06:20:00 +0700 Subject: [PATCH 95/97] ROADMAP: the command sweeps in the progress log (#1059) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 956efade7..e6abac7af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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 | From 1da3c0c05286bda36fa3e16173b3e4088e93826f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 06:20:14 +0700 Subject: [PATCH 96/97] ROADMAP: EPS arcs with huge angles in the progress log (#1062) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index e6abac7af..1ef527abc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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 | From 1819921e03e44445196ab5af9ab468926ef12b2c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 9 Oct 2026 19:55:53 -0700 Subject: [PATCH 97/97] Align: a compound path or selected group that contains the key object stays in place Align and Distribute Spacing move a selected compound path or group as one object, and they found the key object only when its own id was among the objects they move. When the key was inside a compound path or a selected group, aligning to the key moved that compound path or group like any other object, and the key moved with it. In that case, Distribute Spacing with a spacing held the first object still instead of the key. The key object can end up inside a selected group or a compound path. Dragging its row into a group in the Layers panel (layer.move) keeps the selection and the key. A Selection-tool Shift-click on that group, when it is unselected and directly in a layer, then adds it to the selection (select.toggle) and keeps the key. Other ways of adding the group keep the key as well: a Shift-click in the Layers panel's selection column or on the group's target circle, a Group Selection click that hits the key itself, select.add, and select.anchors or select.anchorsMany in add or toggle mode. In isolation mode, a Selection-tool click on one of several selected objects in the isolated group makes that object the key. In an isolated compound path, the same click on the outline of a selected member makes that member the key. Align to the key object and Distribute Spacing with a spacing now keep in place the selected object that is the key or contains it. Both commands look for that object among the key and its ancestors, so a group around the key's group also stays in place. Aligning to the key still lines the others up with the key's own bounds. Docs: docs/target-app-parity.md. --- ROADMAP.md | 2 +- crates/engine/src/cmd/object.rs | 17 ++++++--- crates/engine/src/tests.rs | 61 +++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1ef527abc..f945069e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,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). | +| 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). | | 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 832c0d58b..d3569b8a1 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -976,13 +976,22 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu } } +/// Returns the selected root (one of `ids`) that is the key object or contains it, such as the +/// compound path or selected group the key is in. Align to the key object and Distribute Spacing +/// with a spacing keep it in place. +fn key_root(s: &Session, ids: &[NodeId]) -> Option { + let st = s.doc().ok()?; + let ancestry = st.doc.ancestry(st.selection.key?)?; + ids.iter().copied().find(|id| ancestry.contains(id)) +} + fn align(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let preview = preview_bounds(s, p, "object.align")?; let r = reference_rect(s, p, &ids, preview)?; let h = str_param(p, "horizontal"); let v = str_param(p, "vertical"); - let key = s.doc()?.selection.key; + let key = key_root(s, &ids); let moves: Vec<(NodeId, Vec2)> = { let d = &s.doc()?.doc; items_bounds(d, &ids, preview) @@ -1100,9 +1109,9 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { deltas.push((*id, pos - start(r))); pos += size(r) + gap; } - // With a spacing (aligning to a key object) the key object stays where it is: the others - // are spaced from it. - let key = s.doc()?.selection.key; + // With a spacing (aligning to a key object) the key object stays where it is, with the + // compound path or selected group it is in: the others are spaced from it. + let key = key_root(s, &ids); let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1); let moves: Vec<(NodeId, Vec2)> = deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4894c7e88..5b630f6f4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -52,6 +52,67 @@ fn group_ungroup() { assert_eq!(s.doc().unwrap().selection.len(), 2); } +/// Makes 10 pt squares at (0, 90) and (50, 100) into a compound path or group with the commands in +/// `make`, each run on the selection (the last one makes `outer`), draws a 10 pt square `r` at +/// (200, 0), selects the second square and `r`, and sets the selection's `key` to that square +/// → (session, key, outer, r). +fn key_inside(make: &[&str]) -> (Session, NodeId, NodeId, NodeId) { + let mut s = session(); + let a = rect(&mut s, 0.0, 90.0, 10.0, 10.0); + let key = rect(&mut s, 50.0, 100.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0, key.0]})).unwrap(); + let mut outer = key; + for cmd in make { + outer = NodeId(s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap()); + } + let r = rect(&mut s, 200.0, 0.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [key.0, r.0]})).unwrap(); + s.select(|_, sel| sel.key = Some(key)).unwrap(); + (s, key, outer, r) +} + +/// Aligns the selection left to its key object and, after Undo, spaces it 5 pt apart vertically +/// → the top-left corners of `outer` and `r` after each. +fn aligned_then_spaced(s: &mut Session, outer: NodeId, r: NodeId) -> [((f64, f64), (f64, f64)); 2] { + let at = |s: &Session, id| { + let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + (b.x0, b.y0) + }; + s.execute("object.align", &json!({"horizontal": "left", "bounds": "geometric"})).unwrap(); + let aligned = (at(s, outer), at(s, r)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 5, "bounds": "geometric"})).unwrap(); + [aligned, (at(s, outer), at(s, r))] +} + +/// Align and Distribute Spacing move a compound path as one object. With one of its members as the +/// key object, the compound path stays where it is: Align moves the others to the member's edge, +/// and Distribute Spacing spaces them from the compound path. +#[test] +fn a_compound_path_that_contains_the_key_object_stays_in_place() { + let (mut s, key, outer, r) = key_inside(&["object.compoundPath.make"]); + assert_eq!(s.doc().unwrap().selection.key, Some(key)); + let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r); + assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "it stays, the other moved to the key"); + assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "5 pt apart, it stays"); +} + +/// The key object stays the key when the group that contains it is added to the selection, by +/// `select.add` or by the Group Selection tool's click on a selected member. Align and Distribute +/// Spacing move that group as one object, and the group stays where it is, also when the key is in +/// a group inside it. +#[test] +fn a_selected_group_that_contains_the_key_object_stays_in_place() { + for make in [&["object.group"][..], &["object.group", "object.group"]] { + let (mut s, key, outer, r) = key_inside(make); + s.execute("select.add", &json!({"ids": [outer.0]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.key, Some(key), "{make:?}"); + let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r); + assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "{make:?}: it stays, the other moved to the key"); + assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "{make:?}: 5 pt apart, it stays"); + } +} + #[test] fn arrange_order() { let mut s = session(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index fc9c8d072..d6d1f5b8b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,6 +396,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain. - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). + Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself. Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786). - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.