diff --git a/ROADMAP.md b/ROADMAP.md index 41bf5dce..adc74953 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-09 (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); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (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), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2); 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 (~1 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), Variables (data merge), scripting | +| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~4 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), Variables (data merge), 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, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec | @@ -53,9 +53,12 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent 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 55 filters (Artistic, Brush Strokes, Distort, - Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at - Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h. +2. **Photoshop-style raster effects and the Effect Gallery:** about 52 filters (Artistic, Brush Strokes, Distort, + Pixelate, Sketch, Stylize, Texture, Video) 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) and Sharpen › Unsharp Mask; each new filter is now one function, a catalogue row, menu and + translations. Parallelizes well across agents. 26–40 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. @@ -210,7 +213,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 | | 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 | 6–10 | -| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | +| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 24% | ~52 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur and Unsharp Mask are done | 26–40 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | | 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), kinsoku sets 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 | @@ -236,7 +239,7 @@ With 4–6 agents working on disjoint crates (as the layering allows) the wall-c _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 ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust. +effects ~44/54, Photoshop-style raster effects ~4/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust. _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 diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 76b33f8c..7835e382 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -5,7 +5,8 @@ //! //! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble, //! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order. -//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by +//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the +//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Unsharp Mask) are described by //! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond //! the geometry. //! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the @@ -36,6 +37,7 @@ mod distort; mod group; mod live; mod marks; +pub mod pixel; mod raster; mod reshape; pub mod stroke; @@ -61,6 +63,7 @@ pub use group::{ }; pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner}; 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 warp::{WarpStyle, warp_point}; @@ -118,7 +121,7 @@ fn lengths_of(id: &str) -> Lengths { "distort.transform" => always(&["moveH", "moveV"]), "path.offsetPath" => always(&["offset"]), "path.outlineStroke" => always(&["width"]), - "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" => always(&["radius"]), + "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), "stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]), "stylize.dropShadow" => always(&["x", "y", "blur"]), "stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]), @@ -134,6 +137,7 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"]; const STYLIZE: &[&str] = &["Effect", "Stylize"]; const WARP: &[&str] = &["Effect", "Warp"]; const BLUR: &[&str] = &["Effect", "Blur"]; +const SHARPEN: &[&str] = &["Effect", "Sharpen"]; const PATHFINDER: &[&str] = &["Effect", "Pathfinder"]; const PLUGINS: &[&str] = &["Effect", "Plug-ins"]; const ADJUST: &[&str] = &["Effect", "Color Adjustments"]; @@ -265,6 +269,27 @@ pub fn effect_catalog() -> Vec { ), r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})), r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})), + r( + "blur.radial", + "Radial Blur…", + BLUR, + "{amount: 1..100 (10; spin: an arc of `amount`°, zoom: the content scaled by up to about ±amount/2 %), method: \"spin\"|\"zoom\" (\"spin\"), quality: \"draft\"|\"good\"|\"best\" (\"good\"; 16, 64 or 256 samples)} blurs around the object's centre", + json!({"amount": 10.0, "method": "spin", "quality": "good"}), + ), + r( + "blur.smart", + "Smart Blur…", + BLUR, + "{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( + "sharpen.unsharpMask", + "Unsharp Mask…", + SHARPEN, + "{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}", + json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}), + ), ]; v.extend([ g( @@ -428,6 +453,7 @@ pub fn scale_effect(e: &mut Effect, s: f64) { /// Is `id` a raster (painted) effect? pub fn is_raster(id: &str) -> bool { matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian") + || PIXEL_EFFECTS.contains(&id) } /// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour diff --git a/crates/effects/src/pixel/blur.rs b/crates/effects/src/pixel/blur.rs new file mode 100644 index 00000000..29daba6b --- /dev/null +++ b/crates/effects/src/pixel/blur.rs @@ -0,0 +1,129 @@ +//! Blur › Radial Blur and Smart Blur. + +use vectorcraft_geom::{Affine, Point, Vec2}; + +use super::{PixelSpace, Px16, box_blur, to8, to16}; + +/// Zoom's total spread, in log scale: `amount` 100 scales the content between 1/√2 and √2. +pub(super) fn zoom_extent(amount: f64) -> f64 { + (1.0 + amount / 100.0).ln() +} + +/// `buf` (`w` × `h`) sampled bilinearly at pixel index position (`x`, `y`) (pixel centres at +/// whole coordinates); transparent outside. +fn bilinear(buf: &[Px16], w: usize, h: usize, x: f64, y: f64) -> [f32; 4] { + if !(x > -1.0 && y > -1.0 && x < w as f64 && y < h as f64) { + return [0.0; 4]; + } + let (x0, y0) = (x.floor(), y.floor()); + let (fx, fy) = ((x - x0) as f32, (y - y0) as f32); + let (xi, yi) = (x0 as isize, y0 as isize); + let at = |cx: isize, cy: isize| -> [f32; 4] { + if cx < 0 || cy < 0 || cx as usize >= w || cy as usize >= h { + return [0.0; 4]; + } + buf.get(cy as usize * w + cx as usize).map_or([0.0; 4], |p| p.map(f32::from)) + }; + let (a, b, c, d) = (at(xi, yi), at(xi + 1, yi), at(xi, yi + 1), at(xi + 1, yi + 1)); + let (wa, wb, wc, wd) = ((1.0 - fx) * (1.0 - fy), fx * (1.0 - fy), (1.0 - fx) * fy, fx * fy); + std::array::from_fn(|i| a[i] * wa + b[i] * wb + c[i] * wc + d[i] * wd) +} + +/// Radial Blur: the average of the content turned (spin) or scaled (zoom) about the centre over +/// evenly spaced steps spanning `amount`. Each pass averages the previous result with itself +/// turned both ways by half the last step, so `passes` passes average 2^`passes` samples at two +/// lookups a pixel each. +pub(super) fn radial(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, amount: f64, zoom: bool, passes: u32) { + // The centre in pixel index coordinates (pixel centres at whole numbers). + let c = space.to_doc.inverse() * space.center - Vec2::new(0.5, 0.5); + if !(c.x.is_finite() && c.y.is_finite()) { + return; + } + let total = if zoom { zoom_extent(amount) } else { amount.to_radians() }; + let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2()); + // A pixel's reflection in the view (one axis flipped) only turns the spin the other way, + // which a symmetric blur doesn't notice. + let step = |d: f64| if zoom { about(Affine::scale(d.exp())) } else { about(Affine::rotate(d)) }; + let mut cur = to16(px); + let mut next = vec![[0u16; 4]; cur.len()]; + for j in 1..=passes.min(12) { + let d = total / f64::powi(2.0, j as i32 + 1); + let (fwd, back) = (step(d), step(-d)); + // Where a pixel's two samples lie moves by a fixed step along a row. + let (df, db) = (fwd * Point::new(1.0, 0.0) - fwd * Point::ZERO, back * Point::new(1.0, 0.0) - back * Point::ZERO); + for (y, row) in next.chunks_exact_mut(w).enumerate().take(h) { + let (mut f, mut b) = (fwd * Point::new(0.0, y as f64), back * Point::new(0.0, y as f64)); + for out in row.iter_mut() { + let (s, t) = (bilinear(&cur, w, h, f.x, f.y), bilinear(&cur, w, h, b.x, b.y)); + *out = std::array::from_fn(|i| ((s[i] + t[i]) * 0.5).round().clamp(0.0, 65535.0) as u16); + (f, b) = (f + df, b + db); + } + } + std::mem::swap(&mut cur, &mut next); + } + for (p, q) in px.iter_mut().zip(&cur) { + *p = q.map(to8); + } +} + +/// The largest radius (pixels) Smart Blur looks over: farther neighbours are as good as gone. +const MAX_SMART: f64 = 4096.0; + +/// Smart Blur (Normal mode): each pixel averaged with itself and the neighbours within `radius` +/// pixels whose premultiplied channels all lie within `threshold` levels of its own. Wide radii +/// look at `samples` × `samples` neighbours spread over the disc, read from a copy box-blurred +/// over the gaps between them so the blur stays smooth (a neighbour counts when both it and its +/// blurred surroundings are within the threshold, so at most a fringe under the threshold crosses +/// an edge). +pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, threshold: f64, samples: u32) { + if radius.is_nan() || radius < 0.5 { + return; + } + let r = radius.min(MAX_SMART); + let n = samples.clamp(3, 15) as f64; + let k = r.round(); + let (offsets, prefilter): (Vec, usize) = if 2.0 * k < n { + let k = k as i32; + ((-k..=k).flat_map(|dy| (-k..=k).map(move |dx| Vec2::new(dx as f64, dy as f64))).collect(), 0) + } else { + let gap = 2.0 * r / (n - 1.0); + let grid = (0..n as i32).map(|i| -r + gap * i as f64); + (grid.clone().flat_map(|dy| grid.clone().map(move |dx| Vec2::new(dx.round(), dy.round()))).collect(), (gap / 2.0).floor() as usize) + }; + let offsets: Vec<(isize, isize)> = + offsets.into_iter().filter(|o| o.hypot() <= r + 0.5 && *o != Vec2::ZERO).map(|o| (o.x as isize, o.y as isize)).collect(); + let src = to16(px); + let blurred; + let near: &[Px16] = if prefilter > 0 { + let mut b = src.clone(); + box_blur(&mut b, w, prefilter); + blurred = b; + &blurred + } else { + &src + }; + let t = (threshold.clamp(0.0, 255.0) * 257.0) as i32; + for (y, (row, out)) in src.chunks_exact(w).zip(px.chunks_exact_mut(w)).enumerate().take(h) { + for (x, (c, o)) in row.iter().zip(out.iter_mut()).enumerate() { + let mut acc = c.map(u64::from); + let mut count = 1u64; + for (dx, dy) in &offsets { + let (sx, sy) = (x as isize + dx, y as isize + dy); + if sx < 0 || sy < 0 || sx as usize >= w || sy as usize >= h { + continue; + } + let i = sy as usize * w + sx as usize; + let (Some(s), Some(own)) = (near.get(i), src.get(i)) else { continue }; + // The neighbour itself and its surroundings both close enough: edges stay sharp. + let close = |p: &Px16| p.iter().zip(c).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t); + if close(own) && close(s) { + for (a, v) in acc.iter_mut().zip(s) { + *a += *v as u64; + } + count += 1; + } + } + *o = acc.map(|a| to8(((a + count / 2) / count).min(65535) as u16)); + } + } +} diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs new file mode 100644 index 00000000..3d2166c0 --- /dev/null +++ b/crates/effects/src/pixel/mod.rs @@ -0,0 +1,297 @@ +//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › Unsharp +//! Mask): filters over the pixels of an object rendered alone, premultiplied RGBA8. +//! +//! - 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 +//! Settings resolution (as Gaussian Blur's radius does). +//! - The work per pixel is bounded whatever the parameters: blurs are box approximations whose +//! cost doesn't grow with the radius, and sample counts come from the effect's quality. +//! - Beyond the raster's edges is transparency. + +mod blur; +mod sharpen; + +use serde_json::Value; +use vectorcraft_geom::{Affine, Point, Rect}; + +use crate::util::{num, text}; + +/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]). +pub const PIXEL_EFFECTS: [&str; 3] = ["blur.radial", "blur.smart", "sharpen.unsharpMask"]; + +/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths +/// are in document units. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PixelFx { + /// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or + /// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples. + RadialBlur { amount: f64, zoom: bool, passes: u32 }, + /// Blur › Smart Blur (Normal mode): each pixel averaged with the neighbours within `radius` + /// 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 }, + /// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount` + /// (1 = 100 %), where they differ from it by at least `threshold` levels. + UnsharpMask { amount: f64, radius: f64, threshold: f64 }, +} + +/// Where a raster's pixels lie in the document. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PixelSpace { + /// Pixel coordinates (x right, y down, (0, 0) the top-left corner of the first pixel) → + /// document coordinates. + pub to_doc: Affine, + /// The size of a pixel in document units. + pub px: f64, + /// The object's centre (document coordinates): where Radial Blur blurs around. + pub center: Point, +} + +/// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects. +pub(crate) fn parse(id: &str, p: &Value) -> Option { + let quality = |levels: [(&str, u32); 3], default: u32| { + let q = text(p, "quality", ""); + levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v) + }; + Some(match id { + "blur.radial" => PixelFx::RadialBlur { + amount: num(p, "amount", 10.0).clamp(1.0, 100.0), + zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"), + passes: quality([("draft", 4), ("good", 6), ("best", 8)], 6), + }, + "blur.smart" => PixelFx::SmartBlur { + radius: num(p, "radius", 3.0).clamp(0.1, 100.0), + threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0), + samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7), + }, + "sharpen.unsharpMask" => PixelFx::UnsharpMask { + amount: num(p, "amount", 50.0).clamp(1.0, 500.0) / 100.0, + radius: num(p, "radius", 1.0).clamp(0.1, 250.0), + threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0), + }, + _ => return None, + }) +} + +impl PixelFx { + /// How far beyond `bounds` (the content's, document units) the effect can paint. + pub fn outset(&self, bounds: Rect) -> f64 { + // From the centre to the farthest corner, and to the nearest side. + let far = 0.5 * bounds.width().hypot(bounds.height()); + let near = 0.5 * bounds.width().min(bounds.height()); + match *self { + // A rotated corner reaches the circle through the corners. + 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::UnsharpMask { .. } => 0.0, + } + } + + /// How far (document units) from a pixel the content that decides it lies; `None` when that + /// is anywhere in the object (Radial Blur). + pub fn reach(&self) -> Option { + match *self { + PixelFx::RadialBlur { .. } => None, + PixelFx::SmartBlur { radius, .. } => Some(radius), + PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius), + } + } + + /// Run the effect on `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as + /// `space` says. Does nothing when the sizes don't match. + pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace) { + let Some(px) = pixels(data, w, h) else { return }; + let to_px = |len: f64| len / space.px.max(1e-9); + 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::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold), + } + } +} + +/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty). +fn pixels(data: &mut [u8], w: usize, h: usize) -> Option<&mut [[u8; 4]]> { + let (px, rest) = data.as_chunks_mut::<4>(); + (w > 0 && h > 0 && rest.is_empty() && w.checked_mul(h) == Some(px.len())).then_some(px) +} + +/// The largest box radius the blurs use: wider boxes look the same on any raster we make. +const MAX_BOX: usize = 1 << 17; + +/// Radii of three box blurs approximating a Gaussian of `sigma` pixels. +pub fn gauss_boxes(sigma: f64) -> [usize; 3] { + let sigma = if sigma.is_finite() { sigma.clamp(0.0, MAX_BOX as f64 / 2.0) } else { 0.0 }; + let n = 3.0; + let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt(); + let mut wl = w_ideal.floor() as i64; + if wl % 2 == 0 { + wl -= 1; + } + let wu = wl + 2; + let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0); + let m = m_ideal.round() as i64; + let r = |i: i64| ((((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize).min(MAX_BOX); + [r(0), r(1), r(2)] +} + +/// One horizontal box blur of radius `r` over the rows of `w` values of a plane (running sum, +/// zero past the ends). +fn box_plane(src: &[f32], dst: &mut [f32], w: usize, r: usize) { + if r == 0 { + dst.copy_from_slice(src); + return; + } + let norm = 1.0 / (2 * r + 1) as f32; + for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) { + let mut acc: f32 = row.iter().take(r + 1).sum(); + for (x, o) in out.iter_mut().enumerate() { + *o = acc * norm; + if let Some(v) = row.get(x + r + 1) { + acc += v; + } + if let Some(v) = x.checked_sub(r).and_then(|i| row.get(i)) { + acc -= v; + } + } + } +} + +fn transpose(src: &[T], dst: &mut [T], w: usize, h: usize) { + for (y, row) in src.chunks_exact(w).enumerate().take(h) { + for (x, v) in row.iter().enumerate() { + if let Some(d) = dst.get_mut(x * h + y) { + *d = *v; + } + } + } +} + +/// Gaussian-blur a `w` × `h` plane of values in place (three box passes per axis, zero past the +/// edges). Does nothing when the sizes don't match. +pub fn blur_plane(a: &mut Vec, w: usize, h: usize, sigma: f64) { + if sigma < 0.2 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) { + return; + } + let boxes = gauss_boxes(sigma); + let mut tmp = vec![0.0; a.len()]; + for (len, other) in [(w, h), (h, w)] { + for r in boxes { + box_plane(a, &mut tmp, len, r); + std::mem::swap(a, &mut tmp); + } + transpose(a, &mut tmp, len, other); + std::mem::swap(a, &mut tmp); + } +} + +/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones. +pub(super) type Px16 = [u16; 4]; + +pub(super) fn to16(px: &[[u8; 4]]) -> Vec { + px.iter().map(|p| p.map(|v| v as u16 * 257)).collect() +} + +pub(super) fn to8(v: u16) -> u8 { + ((v as u32 + 128) / 257).min(255) as u8 +} + +fn add(acc: &mut [u64; 4], p: &Px16) { + for (a, v) in acc.iter_mut().zip(p) { + *a += *v as u64; + } +} + +fn sub(acc: &mut [u64; 4], p: &Px16) { + for (a, v) in acc.iter_mut().zip(p) { + *a = a.saturating_sub(*v as u64); + } +} + +fn mean(acc: &[u64; 4], n: u64) -> Px16 { + acc.map(|a| ((a + n / 2) / n.max(1)).min(65535) as u16) +} + +/// One box blur of radius `r` along the rows of `w` pixels (transparent past the ends). +fn box_rows(src: &[Px16], dst: &mut [Px16], w: usize, r: usize) { + let n = 2 * r as u64 + 1; + for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) { + let mut acc = [0u64; 4]; + row.iter().take(r + 1).for_each(|p| add(&mut acc, p)); + for (x, o) in out.iter_mut().enumerate() { + *o = mean(&acc, n); + if let Some(p) = row.get(x + r + 1) { + add(&mut acc, p); + } + if let Some(p) = x.checked_sub(r).and_then(|i| row.get(i)) { + sub(&mut acc, p); + } + } + } +} + +/// One box blur of radius `r` down the columns of `w`-wide rows (transparent past the ends), row +/// by row with a running sum per column. +fn box_cols(src: &[Px16], dst: &mut [Px16], w: usize, r: usize) { + let n = 2 * r as u64 + 1; + let rows: Vec<&[Px16]> = src.chunks_exact(w).collect(); + let mut acc = vec![[0u64; 4]; w]; + let add_row = |acc: &mut [[u64; 4]], row: &[Px16]| acc.iter_mut().zip(row).for_each(|(a, p)| add(a, p)); + rows.iter().take(r + 1).for_each(|row| add_row(&mut acc, row)); + for (y, out) in dst.chunks_exact_mut(w).enumerate() { + for (o, a) in out.iter_mut().zip(&acc) { + *o = mean(a, n); + } + if let Some(row) = rows.get(y + r + 1) { + add_row(&mut acc, row); + } + if let Some(row) = y.checked_sub(r).and_then(|i| rows.get(i)) { + acc.iter_mut().zip(*row).for_each(|(a, p)| sub(a, p)); + } + } +} + +/// Box-blur `w`-wide rows of pixels in place with radius `r` along both axes. +pub(super) fn box_blur(px: &mut [Px16], w: usize, r: usize) { + if r == 0 || w == 0 { + return; + } + let mut tmp = vec![[0u16; 4]; px.len()]; + box_rows(px, &mut tmp, w, r); + box_cols(&tmp, px, w, r); +} + +/// Gaussian-blur `w`-wide rows of premultiplied pixels in place (σ = `sigma` pixels, three box +/// passes per axis, transparent past the edges). +pub(super) fn gaussian(px: &mut [Px16], w: usize, sigma: f64) { + if sigma < 0.2 || w == 0 { + return; + } + let mut tmp = vec![[0u16; 4]; px.len()]; + for r in gauss_boxes(sigma) { + box_rows(px, &mut tmp, w, r); + px.copy_from_slice(&tmp); + } + for r in gauss_boxes(sigma) { + box_cols(px, &mut tmp, w, r); + px.copy_from_slice(&tmp); + } +} + +/// Gaussian-blur `data`, `w` × `h` premultiplied RGBA8 pixels, in place (σ = `sigma` pixels). Does +/// nothing when the sizes don't match. +pub fn gaussian_rgba(data: &mut [u8], w: usize, h: usize, sigma: f64) { + if let Some(px) = pixels(data, w, h) + && sigma >= 0.2 + { + let mut wide = to16(px); + gaussian(&mut wide, w, sigma); + for (p, q) in px.iter_mut().zip(&wide) { + *p = q.map(to8); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/pixel/sharpen.rs b/crates/effects/src/pixel/sharpen.rs new file mode 100644 index 00000000..d0699e6e --- /dev/null +++ b/crates/effects/src/pixel/sharpen.rs @@ -0,0 +1,31 @@ +//! Sharpen › Unsharp Mask. + +use super::{gaussian, to16}; + +/// Unsharp Mask: each colour channel pushed away from a Gaussian blur of the pixels (σ = `sigma` +/// pixels) by `amount` times their difference, where that difference reaches `threshold` levels. +/// The blur is premultiplied, so edges against transparency don't darken; coverage stays as it is. +pub(super) fn unsharp(px: &mut [[u8; 4]], w: usize, h: usize, amount: f64, sigma: f64, threshold: f64) { + if sigma.is_nan() || sigma < 0.2 || amount.is_nan() || amount <= 0.0 || h == 0 { + return; + } + let mut blurred = to16(px); + gaussian(&mut blurred, w, sigma); + let (amount, threshold) = (amount as f32, threshold as f32); + for (p, b) in px.iter_mut().zip(&blurred) { + let a = p[3] as f32; + if a == 0.0 { + continue; + } + for i in 0..3 { + let own = p[i] as f32 * 255.0 / a; + let around = if b[3] > 0 { b[i] as f32 * 255.0 / b[3] as f32 } else { own }; + let diff = own - around; + if diff.abs() < threshold || diff == 0.0 { + continue; + } + let v = (own + amount * diff).clamp(0.0, 255.0); + p[i] = (v * a / 255.0).round().clamp(0.0, 255.0) as u8; + } + } +} diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs new file mode 100644 index 00000000..ff27132e --- /dev/null +++ b/crates/effects/src/pixel/tests.rs @@ -0,0 +1,231 @@ +use serde_json::json; +use vectorcraft_doc::Effect; +use vectorcraft_geom::{Affine, Point, Rect}; + +use super::*; +use crate::{RasterFx, raster_effects}; + +/// A `w` × `h` raster whose pixels are document points (origin top-left). +fn space(w: usize, h: usize) -> PixelSpace { + PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(w as f64 / 2.0, h as f64 / 2.0) } +} + +fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec { + let mut out = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + out.extend(f(x, y)); + } + } + out +} + +fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] { + let i = (y * w + x) * 4; + [d[i], d[i + 1], d[i + 2], d[i + 3]] +} + +fn fx(id: &str, p: serde_json::Value) -> PixelFx { + let e = Effect { id: id.into(), params: p, visible: true }; + match raster_effects(&[e]).first() { + Some(RasterFx::Pixel(p)) => *p, + other => panic!("{id}: {other:?}"), + } +} + +fn grey(v: u8) -> [u8; 4] { + [v, v, v, 255] +} + +#[test] +fn params_take_defaults_and_stay_in_range() { + assert_eq!(fx("blur.radial", json!({})), PixelFx::RadialBlur { amount: 10.0, zoom: false, passes: 6 }); + assert_eq!( + fx("blur.radial", json!({"amount": 1e308, "method": "ZOOM", "quality": "best"})), + PixelFx::RadialBlur { amount: 100.0, zoom: true, passes: 8 } + ); + assert_eq!(fx("blur.radial", json!({"amount": -5, "quality": "nonsense"})), PixelFx::RadialBlur { amount: 1.0, zoom: false, passes: 6 }); + assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7 }); + assert_eq!( + fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low"})), + PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5 } + ); + assert_eq!(fx("sharpen.unsharpMask", json!({})), PixelFx::UnsharpMask { amount: 0.5, radius: 1.0, threshold: 0.0 }); + assert_eq!( + fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})), + PixelFx::UnsharpMask { amount: 5.0, radius: 0.1, threshold: 255.0 } + ); + for id in PIXEL_EFFECTS { + assert!(crate::is_raster(id) && crate::effect_info(id).is_some_and(|e| e.raster), "{id}"); + } +} + +#[test] +fn outsets_follow_the_object() { + let b = Rect::new(0.0, 0.0, 100.0, 50.0); + let spin = fx("blur.radial", json!({})); + let far = 0.5 * 100f64.hypot(50.0); + assert!((spin.outset(b) - (far - 25.0)).abs() < 1e-9); + assert!(fx("blur.radial", json!({"method": "zoom", "amount": 100})).outset(b) > 0.4 * far); + assert_eq!(fx("blur.smart", json!({"radius": 4})).outset(b), 4.0); + assert_eq!(fx("sharpen.unsharpMask", json!({})).outset(b), 0.0); + assert_eq!(spin.reach(), None); + assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(), Some(6.0)); +} + +#[test] +fn wrong_sizes_and_empty_rasters_are_left_alone() { + for id in PIXEL_EFFECTS { + let f = fx(id, json!({})); + let mut d = vec![200u8; 4 * 10]; + f.apply(&mut d, 3, 3, &space(3, 3)); + f.apply(&mut d, 0, 10, &space(0, 10)); + f.apply(&mut [], 0, 0, &space(0, 0)); + assert!(d.iter().all(|v| *v == 200), "{id}"); + } +} + +#[test] +fn extreme_parameters_finish() { + let (w, h) = (40, 30); + let src = image(w, h, |x, y| if (x / 5 + y / 5) % 2 == 0 { grey(250) } else { [0, 0, 0, 0] }); + let tiny = PixelSpace { px: 1e-9, ..space(w, h) }; + let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) }; + for id in PIXEL_EFFECTS { + for p in [json!({"amount": 1e308, "radius": 1e308, "threshold": 1e308, "quality": "best"}), json!({"amount": 0, "radius": 0})] { + for s in [tiny, huge, space(w, h)] { + let mut d = src.clone(); + fx(id, p.clone()).apply(&mut d, w, h, &s); + } + } + } +} + +#[test] +fn every_effect_is_deterministic() { + let (w, h) = (32, 24); + let src = image(w, h, |x, y| { + let a = if x > 4 { 255 } else { 128 }; + [((x * 8) as u8).min(a), ((y * 10) as u8).min(a), ((x * y % 256) as u8).min(a), a] + }); + for id in PIXEL_EFFECTS { + let f = fx(id, json!({"amount": 40, "radius": 3, "threshold": 40})); + let (mut a, mut b) = (src.clone(), src.clone()); + f.apply(&mut a, w, h, &space(w, h)); + f.apply(&mut b, w, h, &space(w, h)); + assert_eq!(a, b, "{id}"); + assert_ne!(a, src, "{id} changes the image"); + // Premultiplied stays premultiplied. + assert!(a.chunks_exact(4).all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]), "{id}"); + } +} + +#[test] +fn spin_blur_smears_along_arcs_and_keeps_the_centre() { + let (w, h) = (41, 41); + // A white dot 12 px right of the centre (20.5, 20.5), and one on the centre. + let src = image(w, h, |x, y| if (x == 32 || x == 20) && y == 20 { grey(255) } else { [0, 0, 0, 0] }); + let mut d = src.clone(); + fx("blur.radial", json!({"amount": 60, "quality": "best"})).apply(&mut d, w, h, &space(w, h)); + // 60° of arc around the centre: up to 30° either way, 12 px out: (20.5 + 12 cos 20°, 20.5 ± 12 sin 20°). + assert!(at(&d, w, 31, 16)[3] > 0 && at(&d, w, 31, 24)[3] > 0, "the dot spreads along its circle"); + assert_eq!(at(&d, w, 32, 10)[3], 0, "not beyond the arc"); + assert_eq!(at(&d, w, 26, 20)[3], 0, "not towards the centre"); + assert!(at(&d, w, 20, 20)[3] > 100, "the centre barely moves: {:?}", at(&d, w, 20, 20)); + // Zoom smears along the ray instead. + let mut z = src.clone(); + fx("blur.radial", json!({"amount": 60, "method": "zoom", "quality": "best"})).apply(&mut z, w, h, &space(w, h)); + assert!(at(&z, w, 35, 20)[3] > 0 && at(&z, w, 29, 20)[3] > 0, "the dot streaks along the ray"); + assert_eq!(at(&z, w, 32, 16)[3], 0, "not across it"); +} + +#[test] +fn smart_blur_smooths_noise_and_keeps_edges() { + let (w, h) = (40, 20); + // Left: grey 100 with ±6 noise; right: white. + let src = image(w, h, |x, y| if x < 20 { grey(if (x + y) % 2 == 0 { 94 } else { 106 }) } else { grey(255) }); + let mut d = src.clone(); + fx("blur.smart", json!({"radius": 3, "threshold": 25})).apply(&mut d, w, h, &space(w, h)); + let v = at(&d, w, 8, 10)[0]; + assert!((97..=103).contains(&v), "noise smoothed: {v}"); + assert!(at(&d, w, 19, 10)[0] < 120, "the edge's dark side doesn't take the white"); + assert_eq!(at(&d, w, 20, 10)[0], 255, "the edge's white side stays white"); + // A threshold above the step blurs across it. + let mut all = src.clone(); + fx("blur.smart", json!({"radius": 3, "threshold": 100})).apply(&mut all, w, h, &space(w, h)); + assert_eq!(at(&all, w, 20, 10)[0], 255, "a 155-level step is still past 100"); +} + +#[test] +fn unsharp_mask_overshoots_at_edges() { + let (w, h) = (40, 10); + let src = image(w, h, |x, _| grey(if x < 20 { 100 } else { 150 })); + let mut d = src.clone(); + fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, w, h, &space(w, h)); + assert!(at(&d, w, 19, 5)[0] < 95, "dark side darker: {:?}", at(&d, w, 19, 5)); + assert!(at(&d, w, 20, 5)[0] > 155, "light side lighter: {:?}", at(&d, w, 20, 5)); + assert_eq!(at(&d, w, 5, 5), grey(100), "flat areas stay"); + assert_eq!(at(&d, w, 35, 5), grey(150)); + // Differences under the threshold are left alone. + let mut t = src.clone(); + fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2, "threshold": 60})).apply(&mut t, w, h, &space(w, h)); + assert_eq!(t, src); + // Coverage stays, and a shape's edge against transparency doesn't darken. + let shape = image(w, h, |x, _| if x < 20 { [200, 0, 0, 255] } else { [0, 0, 0, 0] }); + let mut s = shape.clone(); + fx("sharpen.unsharpMask", json!({"amount": 300, "radius": 3})).apply(&mut s, w, h, &space(w, h)); + assert_eq!(s, shape); +} + +#[test] +fn lengths_are_document_units() { + // The same art at twice the resolution, sharpened with the same radius in points: the + // overshoot spans twice the pixels, the same distance. + let edge = |scale: usize| image(40 * scale, 4, move |x, _| grey(if x < 20 * scale { 100 } else { 150 })); + let run = |scale: usize| { + let mut d = edge(scale); + let s = PixelSpace { to_doc: Affine::scale(1.0 / scale as f64), px: 1.0 / scale as f64, center: Point::ZERO }; + fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, 40 * scale, 4, &s); + (0..40 * scale).filter(|x| at(&d, 40 * scale, *x, 2)[0] < 99).count() + }; + let (one, two) = (run(1), run(2)); + assert!(one > 1 && (two as f64 / one as f64 - 2.0).abs() < 0.5, "{one} px at 1×, {two} px at 2×"); +} + +#[test] +fn gaussian_and_plane_blurs_keep_their_mass() { + let (w, h) = (30, 30); + let mut d = image(w, h, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { grey(255) } else { [0, 0, 0, 0] }); + let before: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum(); + gaussian_rgba(&mut d, w, h, 2.0); + let after: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum(); + assert!((before as f64 - after as f64).abs() / (before as f64) < 0.02, "{before} → {after}"); + assert!(at(&d, w, 9, 15)[3] > 0 && at(&d, w, 10, 15)[3] < 255); + let mut plane: Vec = (0..w * h).map(|i| if i == 15 * w + 15 { 100.0 } else { 0.0 }).collect(); + blur_plane(&mut plane, w, h, 2.0); + assert!((plane.iter().sum::() - 100.0).abs() < 0.5 && plane[15 * w + 13] > 0.0 && plane[13 * w + 15] > 0.0); +} + +/// Timings on a 2000 × 2000 raster (`cargo test -p vectorcraft-effects --release -- --ignored +/// --nocapture pixel_timings`). +#[test] +#[ignore] +fn pixel_timings() { + let (w, h) = (2000, 2000); + let src = image(w, h, |x, y| [(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8, 255]); + let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0) }; + let cases = [ + ("blur.radial", json!({"quality": "draft"})), + ("blur.radial", json!({"quality": "good"})), + ("blur.radial", json!({"quality": "best", "method": "zoom"})), + ("blur.smart", json!({"quality": "low"})), + ("blur.smart", json!({"quality": "high", "radius": 30})), + ("sharpen.unsharpMask", json!({"radius": 20})), + ]; + for (id, p) in cases { + let mut d = src.clone(); + let t = std::time::Instant::now(); + fx(id, p.clone()).apply(&mut d, w, h, &s); + println!("{id} {p}: {:?}", t.elapsed()); + } +} diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index ed966f54..7d647ff8 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -4,7 +4,10 @@ use serde_json::Value; use vectorcraft_doc::Effect; use vectorcraft_doc::color::{BlendMode, Color}; +use vectorcraft_geom::Rect; + use crate::merged_params; +use crate::pixel::{self, PixelFx}; use crate::util::*; /// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses @@ -40,6 +43,8 @@ pub enum RasterFx { GaussianBlur { radius: f64, }, + /// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…). + Pixel(PixelFx), } impl RasterFx { @@ -47,13 +52,15 @@ impl RasterFx { pub fn is_below(&self) -> bool { matches!(self, RasterFx::DropShadow { .. } | RasterFx::OuterGlow { .. }) } - /// How far this effect reaches beyond the geometry. - pub fn outset(&self) -> f64 { + /// How far this effect reaches beyond `bounds`, what it paints around (Radial Blur reaches + /// further around larger objects). + pub fn outset(&self, bounds: Rect) -> f64 { match self { RasterFx::DropShadow { dx, dy, blur, .. } => dx.abs().max(dy.abs()) + 1.5 * blur, RasterFx::OuterGlow { blur, .. } => 1.5 * blur, RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0, RasterFx::GaussianBlur { radius } => 1.5 * radius, + RasterFx::Pixel(p) => p.outset(bounds), } } } @@ -113,14 +120,14 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { }, "stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) }, "blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) }, - _ => return None, + id => RasterFx::Pixel(pixel::parse(id, &p)?), }) }) .collect() } -/// How far the visible raster effects of `effects` paint beyond the (effected) geometry, in -/// document units. Geometry effects are accounted for by evaluating them. -pub fn outset(effects: &[Effect]) -> f64 { - raster_effects(effects).iter().map(RasterFx::outset).fold(0.0, f64::max) +/// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected) +/// geometry's, in document units. Geometry effects are accounted for by evaluating them. +pub fn outset(effects: &[Effect], bounds: Rect) -> f64 { + raster_effects(effects).iter().map(|fx| fx.outset(bounds)).fold(0.0, f64::max) } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 2ad398a1..9cdf47fd 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -301,8 +301,8 @@ fn raster_effects_parse_and_outset() { other => panic!("{other:?}"), } assert!(matches!(r[2], RasterFx::InnerGlow { center: true, .. })); - assert!(close(outset(&list), 10.0 + 9.0, 1e-9)); - assert_eq!(outset(&[]), 0.0); + assert!(close(outset(&list, vectorcraft_geom::Rect::ZERO), 10.0 + 9.0, 1e-9)); + assert_eq!(outset(&[], vectorcraft_geom::Rect::ZERO), 0.0); } #[test] diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 512d6035..f7ec8337 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -170,8 +170,10 @@ impl Session { return None; } let d = self.clipboard.to_document(); + // SVG has no filters for the Photoshop-style effects: their objects go in as images. + let flat = super::rasterfx::flatten_pixel_effects(&d); Some(vectorcraft_svg::export( - &d, + flat.as_ref().unwrap_or(&d), &vectorcraft_svg::ExportOptions { artboard: None, object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() }, )) } diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 773a2cb1..1a3cf8e3 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -153,6 +153,9 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result Option { if !n.visible { return None; } - let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&n.appearance.effects), f64::max); let b = match n.children() { Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?, _ => n.visual_bounds()?, }; + let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(effects::outset(&n.appearance.effects, b), f64::max); Some(b.inflate(fx + 1.0, fx + 1.0)) } diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 25df95aa..60e213ee 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -7,6 +7,10 @@ //! effect: the image (with the object's own area knocked out) goes under the untouched vector //! object. Effects that change the object itself (inner glow, feather, Gaussian blur) replace it //! with the image. Expand Appearance makes its images with the same helpers. +//! +//! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style +//! effects (Radial Blur, Unsharp Mask…): SVG export turns only the objects carrying one of those +//! into images the same way ([`flatten_pixel_effects`]). use std::sync::Arc; @@ -167,7 +171,7 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N // effects reach beyond that. let b = vectorcraft_render::painted_bounds(whole)?; let look = out.raster_effects.clone(); - let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(0.0, f64::max) + 2.0 + look.add_around; + let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around; let b = b.inflate(reach, reach); // Whole pixels, and no larger than 64 Mpx. let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt()); @@ -337,20 +341,39 @@ fn placed_symbol(d: &Document, n: &Node) -> Option { Some(Node { kind: NodeKind::Group { children: vec![Arc::new(art)], clip: false }, ..n.clone() }) } -/// Does `n` hold raster effects PDF export draws: its own, its fills' and strokes', its opacity -/// mask's or (groups and layers) its members'? Hidden objects and template layers aren't drawn. -fn needs(n: &Node) -> bool { +/// Which objects' raster effects become images. +#[derive(Clone, Copy, PartialEq)] +enum Which { + /// Every raster effect (PDF). + All, + /// Objects with a Photoshop-style effect, which SVG can't draw (with all their raster effects). + Pixel, +} + +/// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image? +fn imaged(n: &Node, which: Which) -> bool { + let fx = raster_fx(n); + match which { + Which::All => !fx.is_empty(), + Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_))), + } +} + +/// Does `n` hold raster effects that `which` turns into images: its own, its fills' and strokes', +/// its opacity mask's or (groups and layers) its members'? Hidden objects and template layers +/// aren't drawn. +fn needs(n: &Node, which: Which) -> bool { n.visible && !matches!(n.kind, NodeKind::Layer { template: true, .. }) - && (!raster_fx(n).is_empty() - || n.mask.as_ref().is_some_and(|m| needs(&m.art)) - || (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c))))) + && (imaged(n, which) + || n.mask.as_ref().is_some_and(|m| needs(&m.art, which)) + || (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c, which))))) } /// The art of `n`'s opacity mask with its raster effects as images. -fn walk_mask(out: &mut Document, n: &mut Node) { +fn walk_mask(out: &mut Document, n: &mut Node, which: Which) { if let Some(m) = n.mask.as_mut() - && let Some(art) = walk(out, &m.art) + && let Some(art) = walk(out, &m.art, which) { m.art = Arc::new(art); } @@ -359,15 +382,15 @@ fn walk_mask(out: &mut Document, n: &mut Node) { /// `n` with its raster effects (and its members' and its opacity mask's) as images, `None` when it /// has none. An object whose effects all paint below it keeps its vector art over an image of /// them; one whose effects change it (blur, feather, inner glow) becomes the image. -fn walk(out: &mut Document, n: &Node) -> Option { - if !needs(n) { +fn walk(out: &mut Document, n: &Node, which: Which) -> Option { + if !needs(n, which) { return None; } - if raster_fx(n).is_empty() { + if !imaged(n, which) { let mut m = n.clone(); - walk_mask(out, &mut m); + walk_mask(out, &mut m, which); if is_container(&m) - && let Some(ch) = walk_all(out, n.children()?) + && let Some(ch) = walk_all(out, n.children()?, which) && let Some(slot) = m.children_mut() { *slot = ch; @@ -375,30 +398,30 @@ fn walk(out: &mut Document, n: &Node) -> Option { return Some(m); } if let Some(g) = placed_symbol(out, n) { - return Some(walk(out, &g).unwrap_or(g)); + return Some(walk(out, &g, which).unwrap_or(g)); } let below = below_only(n); if !below && let Some(g) = split_items(out, n) { - return Some(walk(out, &g).unwrap_or(g)); + return Some(walk(out, &g, which).unwrap_or(g)); } // An image that can't be made leaves the object as it is (the writer reports its effects). let image = raster_image(out, n, below, false)?; let mut v = n.clone(); strip_raster(&mut v, false); let mut m = if below { - let mut m = walk(out, &v).unwrap_or(v); + let mut m = walk(out, &v, which).unwrap_or(v); put_below(out, &mut m, image); m } else { replace_with_image(v, image) }; - walk_mask(out, &mut m); + walk_mask(out, &mut m, which); Some(m) } /// `nodes` with their raster effects as images, `None` when none has any. -fn walk_all(out: &mut Document, nodes: &[Arc]) -> Option>> { - nodes.iter().any(|n| needs(n)).then(|| nodes.iter().map(|n| walk(out, n).map(Arc::new).unwrap_or_else(|| n.clone())).collect()) +fn walk_all(out: &mut Document, nodes: &[Arc], which: Which) -> Option>> { + nodes.iter().any(|n| needs(n, which)).then(|| nodes.iter().map(|n| walk(out, n, which).map(Arc::new).unwrap_or_else(|| n.clone())).collect()) } /// A copy of `doc` with raster effects turned into images at the document's raster effects @@ -406,26 +429,37 @@ fn walk_all(out: &mut Document, nodes: &[Arc]) -> Option>> { /// type, images, symbol instances, live objects) and on single fills and strokes, in the layers, /// opacity masks, symbol definitions and pattern tiles. pub fn flatten_raster_effects(doc: &Document) -> Option { - let any = |nodes: &[Arc]| nodes.iter().any(|n| needs(n)); - if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art)) && !doc.patterns.iter().any(|p| any(&p.art)) { + flatten(doc, Which::All) +} + +/// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp +/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the +/// other raster effects live), or `None` when there are none. +pub fn flatten_pixel_effects(doc: &Document) -> Option { + flatten(doc, Which::Pixel) +} + +fn flatten(doc: &Document, which: Which) -> Option { + let any = |nodes: &[Arc]| nodes.iter().any(|n| needs(n, which)); + if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art, which)) && !doc.patterns.iter().any(|p| any(&p.art)) { return None; } // Geometry effects first, so the vector objects kept above shadows are final. let baked = effects::bake_document(doc); let src = baked.as_ref().unwrap_or(doc); let mut out = src.clone(); - if let Some(layers) = walk_all(&mut out, &src.layers) { + if let Some(layers) = walk_all(&mut out, &src.layers, which) { out.layers = layers; } for i in 0..out.symbols.len() { - if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a)) + if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a, which)) && let Some(s) = out.symbols.get_mut(i) { s.art = Arc::new(art); } } for i in 0..out.patterns.len() { - if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a)) + if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a, which)) && let Some(p) = out.patterns.get_mut(i) { p.art = art; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d517d99e..de2b512f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1552,6 +1552,8 @@ mod tests_proxyitems; #[cfg(test)] mod tests_puppetwarp; #[cfg(test)] +mod tests_rasterfilters; +#[cfg(test)] mod tests_rastersettings; #[cfg(test)] mod tests_recolor; diff --git a/crates/engine/src/tests_rasterfilters.rs b/crates/engine/src/tests_rasterfilters.rs new file mode 100644 index 00000000..be08f966 --- /dev/null +++ b/crates/engine/src/tests_rasterfilters.rs @@ -0,0 +1,133 @@ +//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › +//! Unsharp Mask): 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. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_geom::Rect; +use vectorcraft_render::{Rendered, Renderer}; + +use super::*; + +/// The new effects with parameters that change a striped square visibly. +const EFFECTS: [(&str, &str); 3] = [ + ("blur.radial", r#"{"amount": 30, "quality": "draft"}"#), + ("blur.smart", r#"{"radius": 8, "threshold": 100}"#), + ("sharpen.unsharpMask", r#"{"amount": 300, "radius": 3}"#), +]; + +/// A group of a red square and a darker red stripe across it; returns the group's id. +fn striped() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color": "#e03020", "ids": [id]})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [id]})).unwrap(); + let stripe = s.execute("shape.rectangle", &json!({"x": 90, "y": 50, "width": 20, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color": "#b04848", "ids": [stripe]})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [stripe]})).unwrap(); + s.execute("select.set", &json!({"ids": [id, stripe]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + (s, g) +} + +fn render(s: &Session) -> Rendered { + Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true) +} + +fn mean_diff(a: &Rendered, b: &Rendered) -> f64 { + let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum(); + sum as f64 / a.pixels.len() as f64 / 255.0 +} + +#[test] +fn listed_applied_edited_and_undone() { + let (mut s, g) = striped(); + let list = s.execute("effect.list", &json!({})).unwrap(); + for (id, _) in EFFECTS { + let e = list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("{id} listed")); + assert_eq!(e["raster"], json!(true), "{id}"); + assert!(e["params"].as_str().unwrap().contains("radius") || id == "blur.radial", "{id}"); + } + let menus: Vec = + 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"])]); + // Junk parameters are stored as given and read clamped; the canvas still draws. + for (id, _) in EFFECTS { + s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": {"amount": 1e308, "radius": -1e308, "threshold": "x", "quality": 7}})) + .unwrap(); + } + let _ = render(&s); + let n = s.doc().unwrap().doc.node(NodeId(g)).unwrap().clone(); + assert_eq!(n.appearance.effects.len(), 3); + assert_eq!(n.appearance.effects[0].params["method"], json!("spin"), "defaults fill the rest"); + s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"method": "zoom"}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().appearance.effects[0].params["method"], json!("zoom")); + for _ in 0..4 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().appearance.effects.is_empty()); +} + +#[test] +fn each_effect_changes_the_canvas_and_draws_the_same_twice() { + for (id, params) in EFFECTS { + let (mut s, g) = striped(); + let plain = render(&s); + let params: Value = serde_json::from_str(params).unwrap(); + s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap(); + let a = render(&s); + assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a)); + assert_eq!(a.pixels, render(&s).pixels, "{id} is deterministic"); + // The multithreaded pipeline draws the same. + let mut mt = Renderer::new(); + mt.threads = 4; + let b = mt.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + assert!(mean_diff(&a, &b) < 0.002, "{id}: multithreaded differs by {}", mean_diff(&a, &b)); + } +} + +#[test] +fn spin_blur_reaches_past_the_corners() { + let (mut s, g) = striped(); + s.execute("effect.apply", &json!({"effect": "blur.radial", "ids": [g], "params": {"amount": 60, "quality": "good"}})).unwrap(); + let img = render(&s); + // Turned corners sweep beyond the square's sides (centre 100, 100; corner 70.7 pt out). + let p = img.pixel(100, 46); + assert!(p[1] < 250, "the arc of the corners reaches above the square: {p:?}"); + assert!(img.pixel(100, 20)[1] > 250, "but not beyond the corners' circle"); +} + +#[test] +fn pdf_and_svg_write_the_effected_object_as_an_image() { + for (id, params) in EFFECTS { + 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(); + let doc = s.doc().unwrap().doc.clone(); + // PDF: an image that reads back as the canvas shows it. + let r = crate::cmd::rasterfx::export_pdf_with_report(&doc, &Default::default()).unwrap(); + assert!(r.warnings.is_empty(), "{id}: {:?}", r.warnings); + let back = vectorcraft_pdf::import(&r.bytes).unwrap(); + let diff = mean_diff(&render(&s), &Renderer::new().render_region(&back, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true)); + assert!(diff < 0.03, "{id}: PDF reads back {:.2}% off", diff * 100.0); + // SVG: the object becomes an image; a Gaussian Blur alone stays a live filter. + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(svg.contains(" Option { } } } - let o = n.appearance.outset() + effects::outset(&n.appearance.effects); - r.map(|r| r.inflate(o, o)) + let s = n.appearance.outset(); + r.map(|r| { + let r = r.inflate(s, s); + let o = effects::outset(&n.appearance.effects, r); + r.inflate(o, o) + }) } /// Bounds used for culling: like `Node::visual_bounds`, but aware of live effects anywhere in @@ -151,7 +160,7 @@ pub(crate) fn cull_bounds(n: &Node) -> Option { } else { art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c))) }?; - let o = effects::outset(&n.appearance.effects); + let o = effects::outset(&n.appearance.effects, b); Some(b.inflate(o, o)) } NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { @@ -161,7 +170,7 @@ pub(crate) fn cull_bounds(n: &Node) -> Option { // The reshaped art (a symbol's art needs the document: its instance box stands in). _ if has_object_fx(n) => { let b = effects::reshape(n, None).and_then(|r| r.visual_bounds()).or_else(|| n.visual_bounds())?; - let o = effects::outset(&n.appearance.effects); + let o = effects::outset(&n.appearance.effects, b); Some(b.inflate(o, o)) } _ => n.visual_bounds(), @@ -187,6 +196,56 @@ fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color { peniko::Color::new([r, g, b, 1.0]) } +/// Most pixels the raster of [`Renderer::pixel_content`] holds (a 4K screen's): larger content is +/// cropped to the view when its effects only look nearby, else rendered at a lower resolution. +const PIXEL_BUDGET: f64 = 8.4e6; + +/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the +/// content itself), in stack order. +fn content_changing(fx: &&RasterFx) -> bool { + matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_)) +} + +/// How far (document units) from a pixel the content deciding it after `fx` lies; `None` when +/// anywhere in the object. +fn looks_around(fx: &RasterFx) -> Option { + match fx { + RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => Some(1.5 * radius), + RasterFx::Pixel(p) => p.reach(), + _ => Some(0.0), + } +} + +/// The content of an object with Photoshop-style effects as filtered pixels, drawn at +/// (`anchor`'s view position + `rel`) scaled by 1 / `k` (see [`Renderer::pixel_content`]). +pub(crate) struct PixelEntry { + node: Node, + linear: [u64; 4], + anchor: vectorcraft_geom::Point, + rel: (f64, f64), + k: f64, + image: std::sync::Arc, + pub(crate) stamp: u64, +} + +/// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys. +fn linear_key(view: Affine) -> [u64; 4] { + let c = view.as_coeffs(); + [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()] +} + +/// Draw `image` with its top-left at (`x`, `y`) device pixels, `1 / k` device pixels a pixel. +fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc, (x, y): (f64, f64), k: f64) { + let (w, h) = (image.width() as f64, image.height() as f64); + ctx.set_transform(Affine::translate((x, y)) * Affine::scale(1.0 / k)); + // On whole pixels at full size the cheapest sampler is exact. + let quality = if k < 1.0 { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; + let sampler = peniko::ImageSampler { quality, ..Default::default() }; + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); + ctx.set_transform(Affine::IDENTITY); +} + /// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to /// the view-space position of `anchor` (a document point). pub(crate) struct ShadowEntry { @@ -199,73 +258,6 @@ pub(crate) struct ShadowEntry { pub(crate) stamp: u64, } -/// Box sizes approximating a Gaussian of `sigma` with three box blurs. -fn gauss_boxes(sigma: f64) -> [usize; 3] { - let n = 3.0; - let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt(); - let mut wl = w_ideal.floor() as i64; - if wl % 2 == 0 { - wl -= 1; - } - let wu = wl + 2; - let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0); - let m = m_ideal.round() as i64; - let r = |i: i64| (((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize; - [r(0), r(1), r(2)] -} - -/// One horizontal box-blur pass of radius `r` over rows of `w` (running sum, edges as zero). -fn box_blur_h(src: &[f32], dst: &mut [f32], w: usize, h: usize, r: usize) { - if r == 0 { - dst.copy_from_slice(src); - return; - } - let norm = 1.0 / (2 * r + 1) as f32; - for y in 0..h { - let row = &src[y * w..(y + 1) * w]; - let out = &mut dst[y * w..(y + 1) * w]; - let mut acc: f32 = row.iter().take(r + 1).sum(); - for x in 0..w { - out[x] = acc * norm; - if x + r + 1 < w { - acc += row[x + r + 1]; - } - if x >= r { - acc -= row[x - r]; - } - } - } -} - -fn transpose(src: &[f32], dst: &mut [f32], w: usize, h: usize) { - for y in 0..h { - for x in 0..w { - dst[x * h + y] = src[y * w + x]; - } - } -} - -/// Gaussian-blur an alpha plane in place (three box passes per axis). -fn blur_alpha(a: &mut Vec, w: usize, h: usize, sigma: f64) { - if sigma < 0.2 { - return; - } - let boxes = gauss_boxes(sigma); - let mut tmp = vec![0.0; a.len()]; - for r in boxes { - box_blur_h(a, &mut tmp, w, h, r); - std::mem::swap(a, &mut tmp); - } - transpose(a, &mut tmp, w, h); - std::mem::swap(a, &mut tmp); - for r in boxes { - box_blur_h(a, &mut tmp, h, w, r); - std::mem::swap(a, &mut tmp); - } - transpose(a, &mut tmp, h, w); - std::mem::swap(a, &mut tmp); -} - impl Renderer { /// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster: /// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused @@ -287,8 +279,7 @@ impl Renderer { RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color), _ => return false, }; - let c = f.view.as_coeffs(); - let linear = [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]; + let linear = linear_key(f.view); let key = (n as *const Node as usize, i, f.ink); let stamp = self.stamp; let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node == *n); @@ -317,7 +308,7 @@ impl Renderer { off.render(&mut pm, &mut self.resources); let (wu, hu) = (w as usize, h as usize); let mut a: Vec = pm.data().iter().map(|p| p.a as f32).collect(); - blur_alpha(&mut a, wu, hu, blur / 2.0 / f.px); + effects::pixel::blur_plane(&mut a, wu, hu, blur / 2.0 / f.px); let [cr, cg, cb] = f.ink.rgb(&color); let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8; for (px, av) in pm.data_mut().iter_mut().zip(&a) { @@ -504,7 +495,7 @@ impl Renderer { c.pop_layer(); }); } - // The content itself (blurred / feathered). + // The content itself (blurred / feathered / filtered). let blur: f64 = rfx .iter() .map(|fx| match fx { @@ -512,7 +503,9 @@ impl Renderer { _ => 0.0, }) .sum(); - if blur > 0.0 { + if rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) { + self.pixel_content(ctx, f, content, rfx, paint); + } else if blur > 0.0 { self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| { let mut layers = 0; for fx in rfx { @@ -580,6 +573,105 @@ impl Renderer { } } + /// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather and the + /// Photoshop-style filters) in stack order on its pixels: rendered offscreen over all it can + /// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels: + /// larger content is cropped to the view when every effect only looks nearby, else rendered + /// at a lower resolution. A whole raster at full resolution is cached while the object and the + /// zoom stay the same (panning only moves it). + fn pixel_content( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + content: &Content, + rfx: &[RasterFx], + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) { + let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect(); + // Where the result can paint: each effect spreads what the ones before it painted. + let reach = chain.iter().fold(content.reach, |r, fx| { + let o = fx.outset(r); + r.inflate(o, o) + }); + let full = f.view.transform_rect_bbox(reach).inflate(2.0, 2.0); + let (sw, sh) = (ctx.width() as f64, ctx.height() as f64); + if !(full.area().is_finite() && full.x1 > 0.0 && full.y1 > 0.0 && full.x0 < sw && full.y0 < sh) { + return; + } + let fits = full.area() <= PIXEL_BUDGET; + // Device pixels from a pixel to the content deciding it (`None`: anywhere). + let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx).map(|d| m + d / f.px)); + let region = match margin { + Some(m) if !fits => full.intersect(Rect::new(0.0, 0.0, sw, sh).inflate(m, m)), + _ => full, + }; + let (x0, y0) = (region.x0.floor(), region.y0.floor()); + let (rw, rh) = ((region.x1 - x0).max(1.0), (region.y1 - y0).max(1.0)); + let side = u16::MAX as f64; + let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0); + let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side)); + // The cached raster, when the whole content was rendered at full size at this zoom. + let linear = linear_key(f.view); + let key = content.cache.filter(|_| fits && k >= 1.0).map(|slot| (content.node as *const Node as usize, slot, f.ink)); + let stamp = self.stamp; + if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| e.linear == linear && e.node == *content.node) { + e.stamp = stamp; + let p = f.view * e.anchor; + let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k); + draw_scaled(ctx, image, at, k); + return; + } + let (wu, hu) = (w as u16, h as u16); + let view = Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view; + let to_doc = view.inverse(); + let px = f.px / k; + let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f }; + let mut off = f.offscreen_context(wu, hu); + self.inside_layer(|r| paint(r, &mut off, &frame)); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(wu, hu); + off.render(&mut pm, &mut self.resources); + let space = effects::PixelSpace { to_doc, px, center: content.reach.center() }; + let (wz, hz) = (wu as usize, hu as usize); + for fx in chain { + match fx { + RasterFx::GaussianBlur { radius } => effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px), + RasterFx::Feather { radius } => { + effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px); + if let Some((g, rule)) = content.outline { + self.clip_pixels(&mut pm, &frame, g, rule); + } + } + RasterFx::Pixel(p) => p.apply(pm.data_as_u8_slice_mut(), wz, hz, &space), + _ => {} + } + } + let image = std::sync::Arc::new(pm); + if let Some(key) = key { + let anchor = content.reach.origin(); + let p = f.view * anchor; + let rel = (x0 - p.x, y0 - p.y); + self.pixel_fx.insert(key, PixelEntry { node: content.node.clone(), linear, anchor, rel, k, image: image.clone(), stamp }); + } + draw_scaled(ctx, image, (x0, y0), k); + } + + /// Keep `pm`'s pixels only inside `g` filled by `rule` as `frame` draws it (Feather's clip). + fn clip_pixels(&mut self, pm: &mut vello_cpu::Pixmap, frame: &Frame, g: &BezPath, rule: FillRule) { + let mut m = frame.offscreen_context(pm.width(), pm.height()); + m.set_transform(frame.view); + m.set_fill_rule(fill_rule(rule)); + m.set_paint(peniko::Color::BLACK); + m.fill_path(g); + m.flush(); + let mut mask = vello_cpu::Pixmap::new(pm.width(), pm.height()); + m.render(&mut mask, &mut self.resources); + for (p, c) in pm.data_mut().iter_mut().zip(mask.data()) { + let keep = |v: u8| ((v as u32 * c.a as u32 + 127) / 255) as u8; + *p = vello_cpu::color::PremulRgba8 { r: keep(p.r), g: keep(p.g), b: keep(p.b), a: keep(p.a) }; + } + } + /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw` /// applies `composite` (blend mode, opacity) on its filter layer. On a multithreaded context /// the layer is drawn into an offscreen single-threaded context covering `reach` (document @@ -885,4 +977,38 @@ mod tests { assert!(worst <= 4, "{name}: max channel difference {worst}"); } } + + #[test] + fn photoshop_filters_draw_and_follow_a_pan_from_the_cache() { + let d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, vec![("blur.radial", json!({"amount": 40, "quality": "draft"}))]); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let a = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert!(lum(a.pixel(31, 31)) > 60, "the spin softens the corners: {:?}", a.pixel(31, 31)); + assert_eq!(lum(a.pixel(50, 50)), 0, "the centre stays"); + assert!(lum(a.pixel(50, 28)) < WHITE, "turned corners reach past the sides"); + // Panned: the cached raster moves with the view. + let b = r.render(&d, 100, 100, Affine::translate((10.0, 3.0)), &opts); + for (x, y) in [(31, 31), (50, 28), (69, 50), (40, 66)] { + assert_eq!(a.pixel(x, y), b.pixel(x + 10, y + 3), "({x}, {y})"); + } + // A fresh renderer draws the panned view the same. + let c = Renderer::new().render(&d, 100, 100, Affine::translate((10.0, 3.0)), &opts); + assert_eq!(b.pixels, c.pixels); + } + + #[test] + fn photoshop_filters_on_content_larger_than_the_budget() { + // Zoomed in 200×: the 40 pt square covers 8000 × 8000 px around a 200 × 200 view. + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let view = Affine::translate((100.0, 100.0)) * Affine::scale(200.0) * Affine::translate((-70.0, -50.0)); + for (id, p) in [("sharpen.unsharpMask", json!({"amount": 200})), ("blur.smart", json!({})), ("blur.radial", json!({"quality": "draft"}))] { + let d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::rgb(0.2, 0.4, 0.8), vec![(id, p)]); + let img = Renderer::new().render(&d, 200, 200, view, &opts); + // The view shows the square's right side half way down: inside left, outside right. + assert!(lum(img.pixel(40, 100)) < 600, "{id}: inside {:?}", img.pixel(40, 100)); + // (Smart Blur's wide samples read a blurred copy: a faint fringe under its threshold.) + assert!(lum(img.pixel(190, 100)) >= WHITE - 3 * 25, "{id}: outside {:?}", img.pixel(190, 100)); + } + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7f3ccac0..3bf40288 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -316,6 +316,9 @@ pub struct Renderer { live: live::LiveCache, /// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`). shadows: PtrMap<(usize, usize, Ink), fx::ShadowEntry>, + /// Objects' content run through Photoshop-style effects, per object and content slot (see + /// `fx`): kept only while drawn. + pixel_fx: PtrMap<(usize, usize, Ink), fx::PixelEntry>, /// Whether the group being drawn is a knockout group (what its neutral children inherit). knockout: bool, /// Address of the knockout-group element being drawn as its knockout shape: at full object @@ -421,6 +424,7 @@ impl Renderer { stats: FrameStats::default(), brushes: Default::default(), shadows: PtrMap::default(), + pixel_fx: PtrMap::default(), live: live::LiveCache::default(), knockout: false, shape_of: 0, @@ -491,6 +495,8 @@ impl Renderer { if self.shadows.len() > 256 { self.shadows.retain(|_, e| g - e.stamp <= 3); } + // Filtered rasters are large: only those of the last frame stay. + self.pixel_fx.retain(|_, e| e.stamp == g); if !inks { proof::post(&mut pixels, opts); } diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 4b7ea764..00003642 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -584,6 +584,7 @@ impl Rules<'_> { } RasterFx::GaussianBlur { radius } => filter = Some(format!("blur({})", self.len(radius / 2.0))), RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => art.unsupported("inner glows or feathering"), + RasterFx::Pixel(_) => art.unsupported("Photoshop-style raster effects"), } } if !shadows.is_empty() { diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index a7a803dd..6e7bfff6 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1486,7 +1486,9 @@ impl Writer<'_> { } /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` - /// per effect, the first effect innermost (the reference app's stacking order). + /// per effect, the first effect innermost (the reference app's stacking order). Photoshop-style + /// filters have no SVG equivalent: they are left out with a warning (exports from the app turn + /// such objects into images first). fn filtered(&mut self, n: &Node) { let mut inner = n.clone(); inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); @@ -1506,9 +1508,15 @@ impl Writer<'_> { } return 0; } - let reach: f64 = fx.iter().map(RasterFx::outset).sum::() + 2.0; - let region = n.visual_bounds().map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); + let bounds = n.visual_bounds(); + let reach: f64 = fx.iter().map(|f| f.outset(bounds.unwrap_or_default())).sum::() + 2.0; + let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); + let mut opened = 0; for f in fx.iter().rev() { + if matches!(f, RasterFx::Pixel(_)) { + self.warn("SVG has no Photoshop-style raster effects (Radial Blur, Smart Blur, Unsharp Mask…): they are left out"); + continue; + } let fid = self.fresh_id("filter"); let region_attr = match region { Some(r) => format!( @@ -1557,6 +1565,7 @@ impl Writer<'_> { sd(*radius) ), RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), + RasterFx::Pixel(_) => continue, }; // Filters composite normally: a shadow's or glow's other blend mode is recorded for import. let mode = match f { @@ -1570,8 +1579,9 @@ impl Writer<'_> { self.def(1, &format!("{body}")); self.line(&format!("")); self.depth += 1; + opened += 1; } - fx.len() + opened } fn close_filters(&mut self, opened: usize) { diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index dec0fdda..fc8bec44 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -12,7 +12,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { body: |app, ui, d| { let command = d.str("__command"); let lengths = lengths(&command); - form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), app.session.general_unit()); + form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), &|_| 0, app.session.general_unit()); false }, confirm, diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index dd95aef4..d02e8eb0 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -131,7 +131,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let changed = match vectorcraft_plugins::effect::installed(&id) { Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params), None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d), - None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|_| None, app.session.general_unit()), + None => { + let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default(); + let unit = app.session.general_unit(); + form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|k| choices(&id, k), &|k| doc_rank(doc, k), unit) + } }; ui.add_space(6.0); let mut pv = d.bool("preview"); @@ -148,6 +152,26 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The parameters of the effect dialogs that pick one of some values, as (label, value). +fn choices(effect: &str, key: &str) -> Option { + match (effect, key) { + ("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")]), + _ => None, + } +} + +/// Where parameter `key` comes in the effect's parameter documentation `doc` (`{radius: …, +/// threshold: …}`), which lists them in its dialog's order; the end when it isn't there. +pub(super) fn doc_rank(doc: &str, key: &str) -> usize { + let starts = |at: usize| doc.get(..at).and_then(|s| s.chars().last()).is_some_and(|c| c == '{' || c == ' '); + doc.match_indices(key) + .map(|(at, _)| at) + .find(|at| starts(*at) && doc.get(at + key.len()..).is_some_and(|rest| rest.starts_with(':') || rest.starts_with("?:"))) + .unwrap_or(usize::MAX) +} + /// A slider of a colour adjustment dialog: (effect, field, label, range, rail colour at 0..1). type Slider = (&'static str, &'static str, &'static str, (f64, f64), fn(f32) -> egui::Color32); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index d18b27f3..cc5e106e 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -197,19 +197,22 @@ pub(super) type Choices = &'static [(&'static str, &'static str)]; /// Generic editor for command/effect parameters: numbers, booleans, strings and colours; the /// parameters `is_length` names are distances shown and typed in `unit`, those `choices` gives -/// choices for are dropdowns. Returns true when a value changed. +/// choices for are dropdowns. Fields come in `rank` order (by name among equals). Returns true when +/// a value changed. pub(super) fn param_fields( ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn(&str) -> bool, choices: &dyn Fn(&str) -> Option, + rank: &dyn Fn(&str) -> usize, unit: Unit, ) -> bool { let t = Tokens::get(ui.ctx()); let mut changed = false; egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - let keys: Vec<(String, Value)> = + let mut keys: Vec<(String, Value)> = d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect(); + keys.sort_by_key(|(k, _)| rank(k)); for (k, v) in keys { ui.label(egui::RichText::new(humanized(&k)).color(t.text)); if let Some(cur) = crate::widgets::blend_param(&k, &v) { diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 1446b910..52015f88 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -166,6 +166,30 @@ fn effect_preview_is_kept_as_one_undo_step() { assert_eq!(app.session.doc().unwrap().doc, before); } +#[test] +fn raster_filter_dialogs_preview_and_apply() { + for (effect, key, value) in [("blur.radial", "method", "zoom"), ("blur.smart", "quality", "high"), ("sharpen.unsharpMask", "amount", "120")] { + let mut app = app(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": effect})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + let v: serde_json::Value = value.parse::().map_or_else(|_| json!(value), |n| json!(n)); + d.fields.insert(key.into(), v.clone()); + frame(&mut app, Default::default()); + assert!(app.session.in_interaction(), "{effect}: previews"); + confirm(&mut app).unwrap(); + let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + assert_eq!(n.appearance.effects.len(), 1, "{effect}"); + assert_eq!(n.appearance.effects[0].params[key], v, "{effect}"); + } + // Fields follow the effect's documented order (its dialog's), not the alphabet. + let doc = vectorcraft_effects::effect_info("blur.smart").unwrap().params; + let ranks: Vec = ["radius", "threshold", "quality"].iter().map(|k| super::effect::doc_rank(doc, k)).collect(); + assert!(ranks.windows(2).all(|w| w[0] < w[1]), "{ranks:?}"); + assert_eq!(super::effect::doc_rank(doc, "nope"), usize::MAX); +} + #[test] fn effect_dialog_applies_to_its_appearance_item() { let mut app = app(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 191f56ba..8ec36b24 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -643,3 +643,9 @@ Constrain Omezit Free Transform Volná transformace Perspective Distort Perspektivní deformace + +# Photoshop-style raster effects (Effect menu, their dialogs). + Radial Blur… Radiální rozostření… + Smart Blur… Chytré rozostření… + Sharpen Zostření + Unsharp Mask… Maska rozostření… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index de6bb819..290ea0bf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -11,6 +11,23 @@ # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, # tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. +# Photoshop-style raster effects (Effect menu, their dialogs). + Radial Blur… Desenfoque radial… + Smart Blur… Desenfoque inteligente… + Sharpen Enfocar + Unsharp Mask… Máscara de enfoque… + Radial Blur Desenfoque radial + Smart Blur Desenfoque inteligente + Unsharp Mask Máscara de enfoque + Gaussian Blur Desenfoque gaussiano + Method Método + Quality Calidad + Radius Radio + Spin Giro + Draft Borrador + Good Buena + Best Óptima + # Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests). # A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the # leading/trailing spaces of fragments exactly as they are. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 44e11138..b249163c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -12,6 +12,23 @@ # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, # tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. +# Photoshop-style raster effects (Effect menu, their dialogs). + Radial Blur… Flou radial… + Smart Blur… Flou intelligent… + Sharpen Netteté + Unsharp Mask… Masque de netteté… + Radial Blur Flou radial + Smart Blur Flou intelligent + Unsharp Mask Masque de netteté + Gaussian Blur Flou gaussien + Method Méthode + Quality Qualité + Radius Rayon + Spin Rotation + Draft Brouillon + Good Bonne + Best Optimale + # Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests). # A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the # leading/trailing spaces of fragments exactly as they are. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7029cd47..f833c0c5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -11,6 +11,23 @@ # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, # tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. +# Photoshop-style raster effects (Effect menu, their dialogs). + Radial Blur… Sfocatura radiale… + Smart Blur… Sfocatura intelligente… + Sharpen Nitidezza + Unsharp Mask… Maschera di contrasto… + Radial Blur Sfocatura radiale + Smart Blur Sfocatura intelligente + Unsharp Mask Maschera di contrasto + Gaussian Blur Sfocatura gaussiana + Method Metodo + Quality Qualità + Radius Raggio + Spin Rotazione + Draft Bozza + Good Buona + Best Ottima + # Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests). # A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the # leading/trailing spaces of fragments exactly as they are. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4475f59d..1540e48d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3105,3 +3105,20 @@ theme Appearance 外観 Constrain 制限 Free Transform 自由変形 Perspective Distort 遠近変形 + +# Photoshop-style raster effects (Effect menu, their dialogs). + Radial Blur… ぼかし (放射状)… + Smart Blur… ぼかし (詳細)… + Sharpen シャープ + Unsharp Mask… アンシャープマスク… + Radial Blur ぼかし (放射状) + Smart Blur ぼかし (詳細) + Unsharp Mask アンシャープマスク + Gaussian Blur ぼかし (ガウス) + Method 方法 + Quality 画質 + Radius 半径 + Spin 回転 + Draft ドラフト + Good 標準 + Best 高品質 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9e4419aa..1d168bca 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2676,3 +2676,9 @@ Constrain Restringir Free Transform Transformação Livre Perspective Distort Distorção de Perspectiva + +# Photoshop-style raster effects (Effect menu, their dialogs). + Radial Blur… Desfoque Radial… + Smart Blur… Desfoque Inteligente… + Sharpen Nitidez + Unsharp Mask… Máscara de Nitidez… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7ddfcf3e..4929c927 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -9,6 +9,23 @@ # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, # tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. +# Photoshop-style raster effects (Effect menu, their dialogs). + Radial Blur… Радиальное размытие… + Smart Blur… Умное размытие… + Sharpen Резкость + Unsharp Mask… Нерезкая маска… + Radial Blur Радиальное размытие + Smart Blur Умное размытие + Unsharp Mask Нерезкая маска + Gaussian Blur Размытие по Гауссу + Method Метод + Quality Качество + Radius Радиус + Spin Вращение + Draft Черновое + Good Хорошее + Best Наилучшее + # Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests). # A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the # leading/trailing spaces of fragments exactly as they are. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 458c23cf..15939595 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3357,3 +3357,20 @@ axis Both 两者 Select Shaper Face 选择 Shaper 区域 Constrain 约束 Perspective Distort 透视扭曲 + +# Photoshop-style raster effects (Effect menu, their dialogs). + Radial Blur… 径向模糊… + Smart Blur… 智能模糊… + Sharpen 锐化 + Unsharp Mask… 反锐化遮罩… + Radial Blur 径向模糊 + Smart Blur 智能模糊 + Unsharp Mask 反锐化遮罩 + Gaussian Blur 高斯模糊 + Method 方法 + Quality 质量 + Radius 半径 + Spin 旋转 + Draft 草稿 + Good 良好 + Best 最佳 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2baedbc5..d44527e2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3135,3 +3135,20 @@ axis Both 兩者 Constrain 強制 Free Transform 任意變形 Perspective Distort 透視扭曲 + +# Photoshop-style raster effects (Effect menu, their dialogs). + Radial Blur… 放射狀模糊… + Smart Blur… 智慧型模糊… + Sharpen 銳利化 + Unsharp Mask… 反銳利化遮罩… + Radial Blur 放射狀模糊 + Smart Blur 智慧型模糊 + Unsharp Mask 反銳利化遮罩 + Gaussian Blur 高斯模糊 + Method 方法 + Quality 品質 + Radius 半徑 + Spin 旋轉 + Draft 草稿 + Good 良好 + Best 最佳 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 362ee257..00b76b15 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3223,6 +3223,10 @@ fn font_items() -> Vec { items.1.clone() } +/// 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]); 2] = [("Blur", &["Effect", "Blur"]), ("Sharpen", &["Effect", "Sharpen"])]; + /// The Effect menu, built from the effects catalogue (vector effects), plus raster effects. fn effect_menu() -> Vec { let cat = vectorcraft_effects::effect_catalog(); @@ -3246,33 +3250,25 @@ fn effect_menu() -> Vec { "Stylize", "SVG Filters", "Warp", - "Blur", ]; let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label); - for sub_name in order { - if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) { - out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id }))); - continue; - } - let items: Vec = cat - .iter() - .filter(|e| e.menu.last().copied() == Some(sub_name)) + // The effects whose catalogue menu path is `path`. + let items_at = |path: &[&str]| -> Vec { + cat.iter() + .filter(|e| e.menu == path) .map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { // No options (Effect → Pathfinder): apply directly, like Illustrator. true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), }) - .collect(); - if sub_name == "Blur" { - // Live-effect plug-ins close the vector effects. - out.extend(crate::dialogs::plugin::effect_menu()); - if !items.is_empty() { - out.push(Sep); - out.push(Item::Header("Raster Effects")); - out.push(sub(sub_name, items)); - } + .collect() + }; + for sub_name in order { + if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) { + out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id }))); continue; } + let items = items_at(&["Effect", sub_name]); if items.is_empty() { let placeholder = match sub_name { "3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")], @@ -3284,8 +3280,25 @@ fn effect_menu() -> Vec { out.push(sub(sub_name, items)); } } + // Live-effect plug-ins close the vector effects. + out.extend(crate::dialogs::plugin::effect_menu()); + let raster: Vec = RASTER_MENUS + .iter() + .filter_map(|(name, path)| { + let items = items_at(path); + (!items.is_empty()).then(|| sub(name, items)) + }) + .collect(); + if !raster.is_empty() { + out.push(Sep); + out.push(Item::Header("Raster Effects")); + out.extend(raster); + } // Anything not placed above (future effects) still shows up. - for e in cat.iter().filter(|e| !top_level(e) && !e.menu.last().is_some_and(|m| order.contains(m))) { + let placed = |e: &vectorcraft_effects::EffectInfo| { + top_level(e) || order.iter().any(|s| e.menu == ["Effect", *s]) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path) + }; + for e in cat.iter().filter(|e| !placed(e)) { out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))); } out