Merge pull request #19 from storytold/m3-paint-stage2

M3 stage 2: swatch and gradient panel depth, stroke export parity, dash alignment, appearance editing, knockout, graphic style links (M3.30–M3.48)
This commit is contained in:
BFlat 2026-10-04 23:55:10 +07:00 committed by GitHub
commit 6808564486
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
99 changed files with 8296 additions and 1302 deletions

4
Cargo.lock generated
View File

@ -4218,10 +4218,12 @@ dependencies = [
"log", "log",
"smallvec", "smallvec",
"thiserror 2.0.21", "thiserror 2.0.21",
"vectorcraft-brush",
"vectorcraft-color", "vectorcraft-color",
"vectorcraft-doc", "vectorcraft-doc",
"vectorcraft-effects", "vectorcraft-effects",
"vectorcraft-geom", "vectorcraft-geom",
"vectorcraft-testkit",
"vectorcraft-text", "vectorcraft-text",
] ]
@ -4251,10 +4253,12 @@ dependencies = [
"image", "image",
"kurbo 0.13.1", "kurbo 0.13.1",
"proptest", "proptest",
"resvg",
"serde_json", "serde_json",
"svgtypes 0.16.1", "svgtypes 0.16.1",
"thiserror 2.0.21", "thiserror 2.0.21",
"usvg 0.48.1", "usvg 0.48.1",
"vectorcraft-brush",
"vectorcraft-color", "vectorcraft-color",
"vectorcraft-doc", "vectorcraft-doc",
"vectorcraft-effects", "vectorcraft-effects",

View File

@ -12,10 +12,10 @@ _Last updated: 2026-10-04._
|---|---| |---|---|
| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** | | Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** |
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** | | Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
| Feature surface vs full Illustrator (weighted, see below) | **~66%** | | Feature surface vs full Illustrator (weighted, see below) | **~67%** |
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~50%** | | Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~50%** |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~460–660 h** one agent · **~115–190 h** with 4–6 agents | | Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~340–540 h** one agent · **~85–155 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~570–830 h** one agent · **~145–235 h** with 4–6 agents | | Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~450–710 h** one agent · **~115–200 h** with 4–6 agents |
### Shipped so far ### Shipped so far
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
@ -48,19 +48,21 @@ _Last updated: 2026-10-04._
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):** - **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours.
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups; Mixed Appearances, Layers target dots and basic-appearance rules. - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups; Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export.
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF.
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF.
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute.
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
- **Formats:** - **Formats:**
- `.vectorcraft` (lossless JSON), SVG import/export, PDF export/import (including PDF-compatible `.ai`). - `.vectorcraft` (lossless JSON), SVG import/export, PDF export/import (including PDF-compatible `.ai`).
- PNG, JPEG and WebP export, Export for Screens, Place. - PNG, JPEG and WebP export, Export for Screens, Place.
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Tests:** ~1,300 automated tests: model-based property tests, a junk-parameter sweep over every command, golden renders, and MCP end-to-end tests over stdio. - **Tests:** ~1,430 automated tests: model-based property tests, a junk-parameter sweep over every command, golden renders, and MCP end-to-end tests over stdio.
## Milestones and estimates ## Milestones and estimates
@ -69,7 +71,7 @@ _Last updated: 2026-10-04._
| M0 | Skeleton + vertical slice | ✅ done | — | | M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 | | M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 | | M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 M3.7–M3.29 done: swatch options, global/spot edits and colour groups; Color Picker and Color panel behaviour; proxy commands; gradients that follow every transform and an interactive annotator; shared stroke geometry, dotted lines, arrowheads; Appearance-panel targeting and per-item effects and opacity; opacity-mask editing reach; clipping by any shape; neutral labels with a vendor-name CI gate (opacity masks, mask-editing mode and the panels were already done). Pending (M3.30–M3.98, 33 packages): swatch panel depth and libraries, tints, gradient panel depth, freeform gradients and gradient on strokes, stroke export parity and Outline Stroke, dash alignment, stroke on type, width profiles, graphic style links and libraries, container appearance, Expand Appearance, knockout and isolation accuracy, overprint and the Attributes panel, Colour Guide and Recolor depth, Flatten Transparency | 160–200 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 M3.7–M3.39 and M3.42–M3.48 done: swatch options, global/spot edits, colour groups and Swatches panel depth (drag and drop, find, panel commands, document colour mode); Color Picker and Color panel behaviour; proxy commands; gradients that follow every transform, an interactive annotator, Gradient panel depth and rebased gradients; shared stroke geometry, dotted lines, arrowheads, SVG/PDF stroke parity, Outline Stroke fidelity, corner-fitted dashes, profiles through dashes; Appearance-panel targeting, per-item effects and opacity, effect drag/edit, effects on every object kind; three-state knockout and page groups; graphic style links and management; opacity-mask editing reach; clipping by any shape. Pending (M3.40–M3.41, M3.49–M3.98): stroke on type, overprint and the Attributes panel, swatch and style libraries, tints and linked gradient stops, freeform gradients and gradient on strokes, width profile library and Width tool depth, Scale Strokes and preview bounds, container appearance, target circles and Expand Appearance, blend accuracy and isolation, layer clipping masks, Colour Guide and Recolor depth, Expand, mask view, CMYK blending, Flatten Transparency | 45–85 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 one engine loader/encoder for every frontend (desktop, web, CLI, control channel, headless MCP open native/SVG/SVGZ/PDF/.ai/.ait/PNG/JPEG/GIF/WebP/TIFF/BMP; `document.formats`; MCP `export` takes artboard/range/options; one PDF page per artboard in Export for Screens; template layers left out of raster exports); Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); Save / Don't Save / Cancel before closing modified documents; PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 one engine loader/encoder for every frontend (desktop, web, CLI, control channel, headless MCP open native/SVG/SVGZ/PDF/.ai/.ait/PNG/JPEG/GIF/WebP/TIFF/BMP; `document.formats`; MCP `export` takes artboard/range/options; one PDF page per artboard in Export for Screens; template layers left out of raster exports); Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); Save / Don't Save / Cancel before closing modified documents; PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 |
@ -83,7 +85,7 @@ _Last updated: 2026-10-04._
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces done; accessibility, Windows/Linux packaging pending | 20–30 | | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces done; accessibility, Windows/Linux packaging pending | 20–30 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
| | **Total to full parity** (one agent; sum of the rows above — matches [Parity estimate](#parity-estimate)) | | **~570–830** | | | **Total to full parity** (one agent; sum of the rows above — matches [Parity estimate](#parity-estimate)) | | **~450–710** |
## Parity estimate ## Parity estimate
@ -94,17 +96,17 @@ time is counted per area from what is missing, calibrated on measured throughput
about 20 medium features (Text Wrap, Graphs, Effect → Pathfinder, export baking…) in ~16 h wall clock including builds about 20 medium features (Text Wrap, Graphs, Effect → Pathfinder, export baking…) in ~16 h wall clock including builds
and CI on a heavily loaded machine — about 0.8 h per medium feature; large subsystems (3D, raster filters, vertical type) and CI on a heavily loaded machine — about 0.8 h per medium feature; large subsystems (3D, raster filters, vertical type)
are counted bottom-up. The M3 rows (colour, strokes, appearance) come from a detailed 92-task M3 plan whose review are counted bottom-up. The M3 rows (colour, strokes, appearance) come from a detailed 92-task M3 plan whose review
found more missing depth than the first estimate; they are calibrated on its first wave, where 6 packages planned at found more missing depth than the first estimate; they are calibrated on its first two waves, where 13 packages planned at
~61 h took ~39 agent-hours. ~120 h took roughly 25–45 agent-hours.
| Area | Weight | Done | Missing (main items) | One agent (h) | | Area | Weight | Done | Missing (main items) | One agent (h) |
|---|---:|---:|---|---:| |---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | rotated persistent bounding box, Start Global Edit, transform nuances | 4–6 | | Selection, transform & align tools | 6 | 85% | rotated persistent bounding box, Start Global Edit, transform nuances | 4–6 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 | | Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 80% | swatch panel depth and libraries (original), tints, linked gradient stops, freeform gradients, gradient on strokes, Expand, Colour Guide and Recolor depth, overprint, Tile Edge Color | 75–95 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 85% | swatch libraries (original), tints, linked gradient stops, freeform gradients, gradient on strokes, Expand, Colour Guide and Recolor depth, overprint, Tile Edge Color | 23–44 |
| Strokes, brushes, width profiles | 5 | 72% | stroke export parity and Outline Stroke fidelity, dash alignment, stroke on type, width profile library, Width tool depth, Scale Strokes, brush options depth, brush libraries (generated in code) | 28–38 | | Strokes, brushes, width profiles | 5 | 80% | stroke on type, width profile library, Width tool depth, Scale Strokes, brush options depth, brush libraries (generated in code) | 6–12 |
| Appearance, transparency, graphic styles, masks | 5 | 87% | appearance editing depth, container appearance, graphic style links and libraries, knockout and isolation accuracy, Flatten Transparency, mask view (Alt-click) | 60–75 | | Appearance, transparency, graphic styles, masks | 5 | 91% | container appearance, target circles, Expand Appearance, graphic style libraries, blend accuracy and isolation, Flatten Transparency, mask view (Alt-click) | 15–29 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 | | 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 | 15% | ~55 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Texture…), resolution setting, raster effects in PDF | 30–45 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 15% | ~55 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Texture…), resolution setting, raster effects in PDF | 30–45 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
@ -121,18 +123,18 @@ found more missing depth than the first estimate; they are calibrated on its fir
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 | | Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 75% | ~14 panels (Tabs, Links, Attributes, Asset Export, SVG Interactivity, Variables, CSS Properties…), Properties per context | 20–30 | | UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 75% | ~14 panels (Tabs, Links, Attributes, Asset Export, SVG Interactivity, Variables, CSS Properties…), Properties per context | 20–30 |
| Libraries, Links, Package | 2 | 15% | Links panel, Package, local libraries | 10–15 | | Libraries, Links, Package | 2 | 15% | Links panel, Package, local libraries | 10–15 |
| **Feature parity** | **103** | **~66%** | | **~460–660** | | **Feature parity** | **103** | **~67%** | | **~340–540** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 | | Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 | | Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
| **Full parity** | | **~50%** | | **~570–830** | | **Full parity** | | **~51%** | | **~450–710** |
With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4 With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4
(integration, review and shared files such as `menus.rs` serialize some work): **~115–190 h** to feature parity, (integration, review and shared files such as `menus.rs` serialize some work): **~85–155 h** to feature parity,
**~145–235 h** to full parity. **~115–200 h** to full parity.
_Inventories (2026-10-02):_ 426 engine commands + ~60 UI commands; 316 menu items wired, ~70 still disabled; 69 of 79 _Inventories (2026-10-02):_ 426 engine commands + ~60 UI commands; 316 menu items wired, ~70 still disabled; 69 of 79
tools implemented (missing: Slice ×2, Touch Type, vertical type ×3, Print Tiling); 32 panels of ~45; 44 live tools implemented (missing: Slice ×2, Touch Type, vertical type ×3, Print Tiling); 32 panels of ~45; 44 live
effects of ~110 (Illustrator effects 44/54, Photoshop-style raster effects 1/56); ~1,050 tests (~1,300 on 2026-10-04). effects of ~110 (Illustrator effects 44/54, Photoshop-style raster effects 1/56); ~1,050 tests (~1,430 on 2026-10-04).
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out, _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 lossless SVG/PDF export of live effects with SVG filters, a documented JSON format, the same app on the web, and every

View File

@ -168,6 +168,18 @@ pub fn duplicate_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<Gra
(v, ni) (v, ni)
} }
/// Swap the colours of stops `a` and `b` (Alt-dropping one stop on another); offsets, opacities
/// and midpoints stay. Out-of-range indices leave the stops unchanged.
pub fn swap_stop_colors(stops: &[GradientStop], a: usize, b: usize) -> Vec<GradientStop> {
let mut v = stops.to_vec();
if a < v.len() && b < v.len() {
let c = v[a].color;
v[a].color = v[b].color;
v[b].color = c;
}
v
}
/// Set the midpoint between stop `i` and `i + 1` (clamped to the diamond's 13–87 %). /// Set the midpoint between stop `i` and `i + 1` (clamped to the diamond's 13–87 %).
pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec<GradientStop> { pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec<GradientStop> {
let mut v = stops.to_vec(); let mut v = stops.to_vec();
@ -268,6 +280,18 @@ impl GradientGeom {
self.aspect = if len > 1e-12 { other / len } else { 1.0 }; self.aspect = if len > 1e-12 { other / len } else { 1.0 };
} }
/// Move the placement from box `from` to box `to`: the start and end keep their position
/// relative to the box (along a side `from` has no extent on, their offset from its centre).
/// The aspect ratio is kept.
pub fn rebase(&mut self, from: Rect, to: Rect) {
let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| {
if (a1 - a0).abs() > 1e-12 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { v - (a0 + a1) / 2.0 + (b0 + b1) / 2.0 }
};
let map = |p: Point| Point::new(axis(p.x, from.x0, from.x1, to.x0, to.x1), axis(p.y, from.y0, from.y1, to.y0, to.y1));
self.start = map(self.start);
self.end = map(self.end);
}
/// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection /// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection
/// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial. /// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial.
pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 { pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 {
@ -320,6 +344,12 @@ impl GradientPaint {
self.geom = Some(self.resolve(bounds)); self.geom = Some(self.resolve(bounds));
} }
} }
/// Move a placed gradient from box `from` to box `to` (see [`GradientGeom::rebase`]).
pub fn rebase(&mut self, from: Rect, to: Rect) {
if let Some(g) = &mut self.geom {
g.rebase(from, to);
}
}
/// Map a placed gradient through `a` (see [`GradientGeom::transform`]). /// Map a placed gradient through `a` (see [`GradientGeom::transform`]).
pub fn transform(&mut self, a: Affine) { pub fn transform(&mut self, a: Affine) {
if let Some(g) = &mut self.geom { if let Some(g) = &mut self.geom {
@ -480,6 +510,16 @@ mod tests {
assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops); assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops);
} }
#[test]
fn swap_exchanges_only_the_colours() {
let mut g = g3();
g.stops[0].opacity = 0.25;
let v = swap_stop_colors(&g.stops, 0, 1);
assert_eq!((v[0].color.to_hex(), v[1].color.to_hex()), ("#ff0000".to_string(), "#ffffff".to_string()));
assert_eq!((v[0].offset, v[0].opacity, v[1].offset, v[1].opacity), (0.0, 0.25, 0.5, 1.0));
assert_eq!(swap_stop_colors(&g.stops, 0, 9), g.stops);
}
#[test] #[test]
fn midpoint_math() { fn midpoint_math() {
let g = g3(); let g = g3();
@ -492,6 +532,21 @@ mod tests {
assert_eq!(v[0].midpoint, 0.87); assert_eq!(v[0].midpoint, 0.87);
} }
#[test]
fn rebase_keeps_relative_positions() {
let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5 };
g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0));
assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}");
// A box without height (a horizontal line) keeps the offset from its centre line.
let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0 };
h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0));
assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}");
// Unplaced paints have nothing to move.
let mut p = GradientPaint::new(Gradient::default());
p.rebase(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 9.0, 9.0));
assert_eq!(p.geom, None);
}
#[test] #[test]
fn pin_fixes_the_fit_once() { fn pin_fixes_the_fit_once() {
let mut p = GradientPaint::new(Gradient::default()); let mut p = GradientPaint::new(Gradient::default());

View File

@ -1,6 +1,6 @@
//! Colour harmony rules (the Color Guide panel). //! Colour harmony rules (the Color Guide panel).
use crate::Color; use crate::{Color, keep_model};
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Harmony { pub enum Harmony {
@ -40,10 +40,10 @@ impl Harmony {
Harmony::Shades => "Shades", Harmony::Shades => "Shades",
} }
} }
/// Colours of the harmony, base colour first. /// Colours of the harmony, base colour first, in the base colour's model.
pub fn apply(self, base: Color) -> Vec<Color> { pub fn apply(self, base: Color) -> Vec<Color> {
let [h, s, v] = base.to_hsb(); let [h, s, v] = base.to_hsb();
let hue = |d: f32| Color::from_hsb(h + d, s, v); let hue = |d: f32| keep_model(base, Color::from_hsb(h + d, s, v));
match self { match self {
Harmony::Complementary => vec![base, hue(180.0)], Harmony::Complementary => vec![base, hue(180.0)],
Harmony::SplitComplementary => vec![base, hue(150.0), hue(210.0)], Harmony::SplitComplementary => vec![base, hue(150.0), hue(210.0)],
@ -53,18 +53,19 @@ impl Harmony {
Harmony::Triad => vec![base, hue(120.0), hue(240.0)], Harmony::Triad => vec![base, hue(120.0), hue(240.0)],
Harmony::Tetrad => vec![base, hue(60.0), hue(180.0), hue(240.0)], Harmony::Tetrad => vec![base, hue(60.0), hue(180.0), hue(240.0)],
Harmony::Square => vec![base, hue(90.0), hue(180.0), hue(270.0)], Harmony::Square => vec![base, hue(90.0), hue(180.0), hue(270.0)],
Harmony::Shades => (0..5).map(|i| Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0))).collect(), Harmony::Shades => (0..5).map(|i| keep_model(base, Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0)))).collect(),
} }
} }
} }
/// Tints and shades row for the Color Guide variation grid. /// Tints and shades row for the Color Guide variation grid, in the base colour's model.
pub fn variations(base: Color, steps: usize) -> Vec<Color> { pub fn variations(base: Color, steps: usize) -> Vec<Color> {
let n = steps.max(1) as f32; let n = steps.max(1) as f32;
(0..steps) (0..steps)
.map(|i| { .map(|i| {
let t = (i as f32 + 1.0) / (n + 1.0); let t = (i as f32 + 1.0) / (n + 1.0);
if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) } let c = if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) };
keep_model(base, c)
}) })
.collect() .collect()
} }
@ -80,4 +81,15 @@ mod tests {
assert_eq!(Harmony::Triad.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hex(), "#00ff00"); assert_eq!(Harmony::Triad.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hex(), "#00ff00");
assert_eq!(variations(Color::rgb(1.0, 0.0, 0.0), 6).len(), 6); assert_eq!(variations(Color::rgb(1.0, 0.0, 0.0), 6).len(), 6);
} }
#[test]
fn harmonies_keep_the_base_model() {
let base = Color::cmyk(0.0, 1.0, 1.0, 0.0);
for h in Harmony::ALL {
assert!(h.apply(base).iter().all(|c| matches!(c, Color::Cmyk { .. })), "{}", h.label());
}
let Color::Cmyk { c, m, y, .. } = Harmony::Complementary.apply(base)[1] else { unreachable!() };
assert!(c > m && c > y, "the complement of red is a cyan: {c} {m} {y}");
assert!(variations(Color::gray(0.4), 4).iter().all(|c| matches!(c, Color::Gray { .. })));
}
} }

View File

@ -205,6 +205,36 @@ impl Color {
let b = other.to_rgb(); let b = other.to_rgb();
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t) Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
} }
/// The colour model this colour is authored in.
pub fn model(&self) -> cms::Model {
match self {
Color::Rgb { .. } => cms::Model::Rgb,
Color::Cmyk { .. } => cms::Model::Cmyk,
Color::Gray { .. } => cms::Model::Gray,
}
}
/// This colour expressed in `model` with the profile-free formulas Edit Colors uses (RGB from
/// the display colour, CMYK by [`Color::to_cmyk`], Gray as ink from luminance); unchanged when it
/// is in `model` already.
pub fn in_model(self, model: cms::Model) -> Color {
if self.model() == model {
return self;
}
match model {
cms::Model::Rgb => {
let [r, g, b] = self.to_rgb();
Color::rgb(r, g, b)
}
cms::Model::Cmyk => {
let [c, m, y, k] = self.to_cmyk();
Color::cmyk(c, m, y, k)
}
cms::Model::Gray => {
let [r, g, b] = self.to_rgb();
Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0))
}
}
}
pub fn model_name(&self) -> &'static str { pub fn model_name(&self) -> &'static str {
match self { match self {
Color::Rgb { .. } => "RGB", Color::Rgb { .. } => "RGB",
@ -214,6 +244,12 @@ impl Color {
} }
} }
/// `new` expressed in the colour model of `orig` ([`Color::in_model`]): results of colour operations
/// (harmonies, blends, inversions, recolouring) keep the model of the colour they came from.
pub fn keep_model(orig: Color, new: Color) -> Color {
new.in_model(orig.model())
}
/// What fills or strokes an object. /// What fills or strokes an object.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")] #[serde(tag = "type", rename_all = "lowercase")]
@ -322,6 +358,19 @@ mod tests {
assert_eq!(Color::rgb(1.0, 1.0, 0.0).invert_keep_model(), Color::rgb(0.0, 0.0, 1.0)); assert_eq!(Color::rgb(1.0, 1.0, 0.0).invert_keep_model(), Color::rgb(0.0, 0.0, 1.0));
} }
#[test]
fn colours_convert_between_models_and_keep_them() {
use cms::Model;
let red = Color::rgb(1.0, 0.0, 0.0);
assert_eq!(red.in_model(Model::Cmyk), Color::cmyk(0.0, 1.0, 1.0, 0.0));
assert_eq!(red.in_model(Model::Rgb), red, "already RGB: unchanged");
assert_eq!(Color::gray(0.25).in_model(Model::Gray), Color::gray(0.25));
assert_eq!(Color::WHITE.in_model(Model::Gray), Color::gray(0.0));
assert_eq!(keep_model(Color::cmyk(0.1, 0.2, 0.3, 0.0), red).model(), Model::Cmyk);
assert_eq!(keep_model(Color::gray(0.5), red).model(), Model::Gray);
assert_eq!(keep_model(red, Color::rgb(0.0, 0.5, 1.0)), Color::rgb(0.0, 0.5, 1.0));
}
#[test] #[test]
fn paint_serde() { fn paint_serde() {
let p = Paint::solid(Color::rgb8(10, 20, 30)); let p = Paint::solid(Color::rgb8(10, 20, 30));

View File

@ -2,6 +2,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::cms::Model;
use crate::{Color, Gradient, GradientKind, GradientStop, Paint}; use crate::{Color, Gradient, GradientKind, GradientStop, Paint};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -26,9 +27,33 @@ fn solid(name: &str, hex: &str) -> Swatch {
Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap()), global: false, spot: false } Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap()), global: false, spot: false }
} }
/// The default swatches of a document in colour `model`: [`base_swatches`] with their RGB colours
/// (gradient stops too) as whole-percent CMYK in a CMYK document. Greys stay Gray.
pub fn default_swatches(model: Model) -> (Vec<Swatch>, Vec<SwatchGroup>) {
let (mut s, mut g) = base_swatches();
if model == Model::Cmyk {
let cmyk = |c: &mut Color| {
if let Color::Rgb { .. } = c {
let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round() / 100.0;
let [cc, m, y, k] = c.to_cmyk();
*c = Color::cmyk(pct(cc), pct(m), pct(y), pct(k));
}
};
for w in s.iter_mut().chain(g.iter_mut().flat_map(|g| g.swatches.iter_mut())) {
match &mut w.paint {
Paint::Solid { color, .. } => cmyk(color),
Paint::Gradient(gp) => gp.gradient.stops.iter_mut().for_each(|st| cmyk(&mut st.color)),
_ => {}
}
}
}
(s, g)
}
/// The default document swatches: None, Registration-like black, white, black, a spectrum, greys, /// The default document swatches: None, Registration-like black, white, black, a spectrum, greys,
/// gradients. (Composition is ours; Illustrator's layout grammar — None first, then specials — is kept.) /// gradients. (The composition is ours; the reference app's layout grammar — None first, then
pub fn default_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) { /// specials — is kept.)
fn base_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let mut s = let mut s =
vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }, solid("White", "#ffffff"), solid("Black", "#000000")]; vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }, solid("White", "#ffffff"), solid("Black", "#000000")];
let spectrum = [ let spectrum = [
@ -110,10 +135,28 @@ mod tests {
#[test] #[test]
fn defaults_start_with_none() { fn defaults_start_with_none() {
let (s, g) = default_swatches(); let (s, g) = default_swatches(Model::Rgb);
assert!(s[0].paint.is_none()); assert!(s[0].paint.is_none());
assert!(s.len() > 30); assert!(s.len() > 30);
assert_eq!(g.len(), 2); assert_eq!(g.len(), 2);
assert_eq!(g[0].swatches.len(), 9); assert_eq!(g[0].swatches.len(), 9);
} }
#[test]
fn cmyk_defaults_are_cmyk_with_the_same_names() {
let (rgb, _) = default_swatches(Model::Rgb);
let (s, g) = default_swatches(Model::Cmyk);
assert_eq!(s.iter().map(|w| &w.name).collect::<Vec<_>>(), rgb.iter().map(|w| &w.name).collect::<Vec<_>>());
let colors = s.iter().chain(g.iter().flat_map(|g| &g.swatches)).flat_map(|w| match &w.paint {
Paint::Solid { color, .. } => vec![*color],
Paint::Gradient(gp) => gp.gradient.stops.iter().map(|st| st.color).collect(),
_ => vec![],
});
assert!(colors.clone().all(|c| !matches!(c, Color::Rgb { .. })));
assert!(colors.clone().any(|c| matches!(c, Color::Gray { .. })), "greys stay Gray");
let white = s.iter().find(|w| w.name == "White").and_then(|w| w.paint.color());
assert_eq!(white, Some(Color::cmyk(0.0, 0.0, 0.0, 0.0)));
let black = s.iter().find(|w| w.name == "Black").and_then(|w| w.paint.color());
assert_eq!(black, Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)));
}
} }

View File

@ -540,14 +540,49 @@ impl Appearance {
_ => None, _ => None,
}) })
} }
/// Does a fill or stroke carry a gradient that is fitted to the bounds on each render? /// Gradient paints of the fills and strokes.
pub fn has_unplaced_gradient(&self) -> bool { fn gradients(&self) -> impl Iterator<Item = &vectorcraft_color::GradientPaint> {
self.items.iter().filter_map(|i| match i {
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. })
| AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => Some(&**g),
_ => None,
})
}
/// Is `self` equal to `other`, placed gradients within rounding (1e-6 pt) of each other, as
/// moving them between boxes leaves them?
pub fn approx_eq(&self, other: &Appearance) -> bool {
if self == other {
return true;
}
let mut a = self.clone();
for (g, o) in a.gradients_mut().zip(other.gradients()) {
if let (Some(ga), Some(go)) = (&mut g.geom, &o.geom)
&& ga.start.distance(go.start) <= 1e-6
&& ga.end.distance(go.end) <= 1e-6
&& (ga.aspect - go.aspect).abs() <= 1e-6
{
*ga = *go;
}
}
a == *other
}
/// Does a fill or stroke carry a gradient whose placement `placed` is (some: placed in document
/// space; none: fitted to the bounds on each render)?
fn has_gradient(&self, placed: bool) -> bool {
self.items.iter().any(|i| match i { self.items.iter().any(|i| match i {
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. })
| AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.geom.is_none(), | AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.geom.is_some() == placed,
_ => false, _ => false,
}) })
} }
/// Does a fill or stroke carry a gradient that is fitted to the bounds on each render?
pub fn has_unplaced_gradient(&self) -> bool {
self.has_gradient(false)
}
/// Does a fill or stroke carry a placed gradient (start and end in document space)?
pub fn has_placed_gradient(&self) -> bool {
self.has_gradient(true)
}
/// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit /// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit
/// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce. /// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce.
pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) { pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) {
@ -564,6 +599,23 @@ impl Appearance {
} }
} }
} }
/// Move placed gradients from an object whose geometric bounds are `from` to one whose bounds
/// are `to`, keeping them at the same place relative to the box (strokes: relative to their
/// stroke-inflated boxes, as they fit).
pub fn rebase_gradients(&mut self, from: vectorcraft_geom::Rect, to: vectorcraft_geom::Rect) {
for i in &mut self.items {
match i {
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) => g.rebase(from, to),
AppearanceItem::Stroke(s) => {
let (f, t) = (s.paint_bounds(from), s.paint_bounds(to));
if let Paint::Gradient(g) = &mut s.paint {
g.rebase(f, t);
}
}
_ => {}
}
}
}
/// Map placed gradients through `a`. /// Map placed gradients through `a`.
pub fn transform_gradients(&mut self, a: vectorcraft_geom::Affine) { pub fn transform_gradients(&mut self, a: vectorcraft_geom::Affine) {
for g in self.gradients_mut() { for g in self.gradients_mut() {
@ -582,10 +634,45 @@ impl Appearance {
} }
} }
impl Appearance {
/// Is any fill, stroke or effect (the object's or an item's) hidden?
pub fn has_hidden(&self) -> bool {
let hidden = |fx: &[Effect]| fx.iter().any(|e| !e.visible);
hidden(&self.effects) || self.items.iter().any(|i| !i.visible() || hidden(i.effects()))
}
/// Make every hidden fill, stroke and effect visible (Show All Hidden Attributes). Returns
/// whether anything was hidden.
pub fn show_all(&mut self) -> bool {
let had = self.has_hidden();
let show = |fx: &mut Vec<Effect>| fx.iter_mut().for_each(|e| e.visible = true);
show(&mut self.effects);
for i in &mut self.items {
match i {
AppearanceItem::Fill(f) => f.visible = true,
AppearanceItem::Stroke(s) => s.visible = true,
}
show(i.effects_mut());
}
had
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn hidden_attributes_show_again() {
let mut a = Appearance::default_art();
assert!(!a.has_hidden() && !a.show_all());
a.stroke_mut().unwrap().visible = false;
a.items[0].effects_mut().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: false });
assert!(a.has_hidden());
assert!(a.show_all());
assert!(!a.has_hidden() && a.items.iter().all(AppearanceItem::visible) && a.items[0].effects()[0].visible);
}
#[test] #[test]
fn basic_appearance() { fn basic_appearance() {
let a = Appearance::default_art(); let a = Appearance::default_art();
@ -594,6 +681,25 @@ mod tests {
assert_eq!(a.stroke_width(), 1.0); assert_eq!(a.stroke_width(), 1.0);
} }
#[test]
fn rebase_moves_fills_by_the_box_and_strokes_by_their_inflated_box() {
use vectorcraft_color::{Gradient, GradientGeom, GradientPaint};
use vectorcraft_geom::{Point, Rect};
let placed = |x: f64| {
let mut g = GradientPaint::new(Gradient::default());
g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0 });
Paint::Gradient(Box::new(g))
};
let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0);
a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0));
let start = |p: Paint| match p {
Paint::Gradient(g) => g.geom.unwrap().start,
_ => unreachable!(),
};
// The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95).
assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0)));
}
#[test] #[test]
fn items_by_index() { fn items_by_index() {
let mut a = Appearance::default_art(); let mut a = Appearance::default_art();

View File

@ -38,6 +38,7 @@ pub use appearance::{
pub use graph::{GraphKind, GraphSpec}; pub use graph::{GraphKind, GraphSpec};
pub use hit::{Hit, HitKind}; pub use hit::{Hit, HitKind};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
pub use node::Knockout;
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use selection::{AnchorRef, Selection}; pub use selection::{AnchorRef, Selection};
@ -206,6 +207,16 @@ pub enum ColorMode {
Cmyk, Cmyk,
} }
impl ColorMode {
/// The colour model new colours take in a document of this mode.
pub fn model(self) -> vectorcraft_color::cms::Model {
match self {
ColorMode::Rgb => vectorcraft_color::cms::Model::Rgb,
ColorMode::Cmyk => vectorcraft_color::cms::Model::Cmyk,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Artboard { pub struct Artboard {
pub id: u32, pub id: u32,
@ -254,11 +265,80 @@ impl Default for GridPrefs {
} }
} }
/// A named graphic style (Graphic Styles panel). /// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphicStyle { pub struct GraphicStyle {
pub name: String, pub name: String,
pub appearance: Appearance, pub appearance: Appearance,
/// Stable id linked objects refer to ([`Node::graphic_style`]); 0 until first needed (styles
/// from older files get one when an object is first linked to them).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub id: u32,
#[serde(default = "one", skip_serializing_if = "skip::is_one")]
pub opacity: f32,
#[serde(default, skip_serializing_if = "skip::is_default")]
pub blend: vectorcraft_color::BlendMode,
#[serde(default, skip_serializing_if = "skip::is_default")]
pub isolate: bool,
#[serde(default, skip_serializing_if = "skip::is_default")]
pub knockout: Knockout,
/// Placed gradients are stored relative to the unit box (0, 0)–(1, 1), so applying the style
/// places them on each object's own bounds. Styles saved before this kept document
/// coordinates (false).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unit_box: bool,
}
fn one() -> f32 {
1.0
}
/// The name of the style new documents list first (Sort by Name keeps it first).
pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style";
impl GraphicStyle {
/// The box a style's placed gradients are stored relative to.
pub const UNIT_BOX: Rect = Rect::new(0.0, 0.0, 1.0, 1.0);
/// A style with `appearance` (its placed gradients, if any, in unit-box space) and default
/// transparency (no id yet).
pub fn new(name: impl Into<String>, appearance: Appearance) -> Self {
Self {
name: name.into(),
appearance,
id: 0,
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: Knockout::Neutral,
unit_box: true,
}
}
/// A style capturing `n`'s transparency and `appearance` (in unit-box space).
pub fn of(name: impl Into<String>, appearance: Appearance, n: &Node) -> Self {
Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) }
}
/// The style's appearance as object `n` takes it: placed gradients stored in unit-box space
/// land at the same place relative to `n`'s geometric bounds.
pub fn appearance_on(&self, n: &Node) -> std::borrow::Cow<'_, Appearance> {
let placed = self.unit_box && self.appearance.has_placed_gradient();
match n.geometric_bounds().filter(|_| placed) {
Some(b) => {
let mut ap = self.appearance.clone();
ap.rebase_gradients(Self::UNIT_BOX, b);
std::borrow::Cow::Owned(ap)
}
None => std::borrow::Cow::Borrowed(&self.appearance),
}
}
/// Give `n` this style's transparency.
pub fn apply_transparency(&self, n: &mut Node) {
(n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout);
}
/// Does `n` have this style's transparency?
pub fn transparency_matches(&self, n: &Node) -> bool {
(n.opacity, n.blend, n.isolate, n.knockout) == (self.opacity, self.blend, self.isolate, self.knockout)
}
} }
/// A symbol definition. /// A symbol definition.
@ -333,6 +413,14 @@ pub struct Document {
/// Foreign data preserved on round-trip. /// Foreign data preserved on round-trip.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub unknown: serde_json::Map<String, serde_json::Value>, pub unknown: serde_json::Map<String, serde_json::Value>,
/// Transparency panel → Page Isolated Blending: the page is an isolated transparency group,
/// so top-level blend modes don't blend with what lies under the page.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub page_isolate: bool,
/// Transparency panel → Page Knockout Group: the page's elements (its layers; neutral layers
/// pass their contents through) knock each other out.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub page_knockout: bool,
} }
fn ppi72() -> f64 { fn ppi72() -> f64 {
@ -342,15 +430,20 @@ fn ppi72() -> f64 {
pub const FORMAT_VERSION: u32 = 1; pub const FORMAT_VERSION: u32 = 1;
impl Document { impl Document {
/// A new document with one artboard of `size` and one layer ("Layer 1"). /// A new RGB document with one artboard of `size` and one layer ("Layer 1").
pub fn new(width: f64, height: f64) -> Self { pub fn new(width: f64, height: f64) -> Self {
let (swatches, swatch_groups) = vectorcraft_color::default_swatches(); Self::new_with_mode(width, height, ColorMode::Rgb)
}
/// [`Document::new`] in colour `mode`, with that mode's default swatches.
pub fn new_with_mode(width: f64, height: f64, mode: ColorMode) -> Self {
let (swatches, swatch_groups) = vectorcraft_color::default_swatches(mode.model());
let mut d = Self { let mut d = Self {
version: FORMAT_VERSION, version: FORMAT_VERSION,
title: "Untitled-1".into(), title: "Untitled-1".into(),
template: false, template: false,
units: Unit::Points, units: Unit::Points,
color_mode: ColorMode::Rgb, color_mode: mode,
artboards: vec![Artboard { artboards: vec![Artboard {
id: 1, id: 1,
name: "Artboard 1".into(), name: "Artboard 1".into(),
@ -376,6 +469,8 @@ impl Document {
mask_edit: None, mask_edit: None,
next_id: 1, next_id: 1,
unknown: Default::default(), unknown: Default::default(),
page_isolate: false,
page_knockout: false,
}; };
let id = d.alloc_id(); let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
@ -633,20 +728,51 @@ impl Document {
} }
} }
/// Graphic styles.
impl Document {
/// Index of the graphic style named `name`.
pub fn graphic_style_index(&self, name: &str) -> Option<usize> {
self.graphic_styles.iter().position(|g| g.name == name)
}
/// The graphic style named `name`.
pub fn graphic_style(&self, name: &str) -> Option<&GraphicStyle> {
self.graphic_styles.iter().find(|g| g.name == name)
}
/// The graphic style with id `id` (0 never matches).
pub fn graphic_style_by_id(&self, id: u32) -> Option<&GraphicStyle> {
self.graphic_styles.iter().find(|g| g.id != 0 && g.id == id)
}
/// An id no graphic style has.
pub fn next_graphic_style_id(&self) -> u32 {
self.graphic_styles.iter().map(|g| g.id).max().unwrap_or(0) + 1
}
/// The id of graphic style `index`, assigning one if it has none yet.
pub fn graphic_style_id(&mut self, index: usize) -> u32 {
if self.graphic_styles[index].id == 0 {
self.graphic_styles[index].id = self.next_graphic_style_id();
}
self.graphic_styles[index].id
}
/// The default name of a new graphic style: the first free "Graphic Style N", N counting on
/// from the number of styles.
pub fn new_graphic_style_name(&self) -> String {
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
}
}
fn default_graphic_styles() -> Vec<GraphicStyle> { fn default_graphic_styles() -> Vec<GraphicStyle> {
use vectorcraft_color::{Color, Paint}; use vectorcraft_color::{Color, Paint};
vec![ let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap());
GraphicStyle { name: "Default Graphic Style".into(), appearance: Appearance::default_art() }, [
GraphicStyle { name: "Black Outline".into(), appearance: Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0) }, GraphicStyle::new(DEFAULT_GRAPHIC_STYLE, Appearance::default_art()),
GraphicStyle { GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)),
name: "Heavy Ink".into(), GraphicStyle::new("Heavy Ink", Appearance::basic(solid("#1b1464"), Paint::solid(Color::BLACK), 4.0)),
appearance: Appearance::basic(Paint::solid(Color::from_hex("#1b1464").unwrap()), Paint::solid(Color::BLACK), 4.0), GraphicStyle::new("Sunshine", Appearance::basic(solid("#fbb03b"), solid("#f15a24"), 2.0)),
},
GraphicStyle {
name: "Sunshine".into(),
appearance: Appearance::basic(Paint::solid(Color::from_hex("#fbb03b").unwrap()), Paint::solid(Color::from_hex("#f15a24").unwrap()), 2.0),
},
] ]
.into_iter()
.zip(1..)
.map(|(g, id)| GraphicStyle { id, ..g })
.collect()
} }
#[cfg(test)] #[cfg(test)]

View File

@ -1417,6 +1417,7 @@ pub fn expanded_group(n: &Node, outline: Outliner) -> Node {
g.blend = n.blend; g.blend = n.blend;
g.isolate = n.isolate; g.isolate = n.isolate;
g.knockout = n.knockout; g.knockout = n.knockout;
g.knockout_shape = n.knockout_shape;
g g
} }

View File

@ -253,8 +253,13 @@ pub struct Node {
pub blend: BlendMode, pub blend: BlendMode,
#[serde(default, skip_serializing_if = "crate::skip::is_default")] #[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub isolate: bool, pub isolate: bool,
/// Knockout Group: whether a container's children knock each other out (see [`Knockout`]).
#[serde(default, skip_serializing_if = "crate::skip::is_default")] #[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub knockout: bool, pub knockout: Knockout,
/// Opacity & Mask Define Knockout Shape: inside a knockout group, this object's opacity and
/// opacity mask scale how much of the objects below it in the group it knocks out.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub knockout_shape: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")] #[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub appearance: Appearance, pub appearance: Appearance,
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
@ -270,6 +275,10 @@ pub struct Node {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<Box<crate::graph::GraphSpec>>, pub graph: Option<Box<crate::graph::GraphSpec>>,
pub kind: NodeKind, pub kind: NodeKind,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
/// that style's look: editing its appearance or transparency breaks the link.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graphic_style: Option<u32>,
} }
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). /// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
@ -307,13 +316,15 @@ impl Node {
opacity: 1.0, opacity: 1.0,
blend: BlendMode::Normal, blend: BlendMode::Normal,
isolate: false, isolate: false,
knockout: false, knockout: Knockout::Neutral,
knockout_shape: false,
appearance: Appearance::default(), appearance: Appearance::default(),
mask: None, mask: None,
trace: None, trace: None,
wrap: None, wrap: None,
graph: None, graph: None,
kind, kind,
graphic_style: None,
} }
} }
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
@ -360,7 +371,12 @@ impl Node {
/// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel /// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel
/// fills an object's target circle otherwise). /// fills an object's target circle otherwise).
pub fn has_default_transparency(&self) -> bool { pub fn has_default_transparency(&self) -> bool {
self.opacity >= 1.0 && self.blend == BlendMode::Normal && !self.isolate && !self.knockout && self.mask.is_none() self.opacity >= 1.0
&& self.blend == BlendMode::Normal
&& !self.isolate
&& self.knockout == Knockout::Neutral
&& !self.knockout_shape
&& self.mask.is_none()
} }
/// Kind name as the Layers panel / Properties panel shows it. /// Kind name as the Layers panel / Properties panel shows it.
pub fn kind_label(&self) -> &'static str { pub fn kind_label(&self) -> &'static str {
@ -635,6 +651,109 @@ impl Node {
} }
} }
/// Knockout Group state of a container (the Transparency panel's three-state checkbox). In a
/// knockout group each child composites against the group's backdrop, so it hides the children
/// below it instead of showing them through its transparency. Neutral passes the enclosing
/// group's setting through to the children. Files from before the three states stored a bool:
/// `true` loads as On, `false` as Neutral.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Knockout {
#[default]
Neutral,
Off,
On,
}
impl Knockout {
pub const ALL: [Knockout; 3] = [Knockout::Neutral, Knockout::Off, Knockout::On];
/// Name in files and command parameters.
pub fn label(self) -> &'static str {
match self {
Knockout::Neutral => "neutral",
Knockout::Off => "off",
Knockout::On => "on",
}
}
/// A name ([`Self::label`], any case) or a bool as older files and commands wrote it.
pub fn from_value(v: &serde_json::Value) -> Option<Self> {
match v {
serde_json::Value::Bool(b) => Some(Self::from(*b)),
serde_json::Value::String(s) => Self::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s.trim())),
_ => None,
}
}
/// The state a click on the checkbox moves to: on → neutral → off → on.
pub fn cycle(self) -> Self {
match self {
Knockout::On => Knockout::Neutral,
Knockout::Neutral => Knockout::Off,
Knockout::Off => Knockout::On,
}
}
/// Whether children of a group with this state knock each other out, inside a group (or page)
/// where that is `enclosing`.
pub fn resolve(self, enclosing: bool) -> bool {
match self {
Knockout::Neutral => enclosing,
Knockout::Off => false,
Knockout::On => true,
}
}
}
impl From<bool> for Knockout {
fn from(b: bool) -> Self {
if b { Knockout::On } else { Knockout::Neutral }
}
}
impl Serialize for Knockout {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.label())
}
}
impl<'de> Deserialize<'de> for Knockout {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Self::from_value(&v).ok_or_else(|| serde::de::Error::custom(format!("invalid knockout {v}")))
}
}
impl Node {
/// Whether the children of this group or layer knock each other out, inside a group (or page)
/// where that is `enclosing`.
pub fn knocks_out(&self, enclosing: bool) -> bool {
matches!(self.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && self.knockout.resolve(enclosing)
}
/// A plain group or layer that is neutral to knockout and has no transparency or appearance
/// of its own: inside a knockout group its children composite as the group's own children.
pub fn passes_knockout_through(&self) -> bool {
matches!(self.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { template: false, .. })
&& self.has_default_transparency()
&& self.appearance.items.is_empty()
&& self.appearance.effects.is_empty()
}
/// The visible elements of a knockout group made of `children` (bottom first): neutral plain
/// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves.
pub fn knockout_elements(children: &[Arc<Node>]) -> Vec<&Arc<Node>> {
fn push<'a>(children: &'a [Arc<Node>], out: &mut Vec<&'a Arc<Node>>) {
for c in children.iter().filter(|c| c.visible) {
match c.children() {
Some(ch) if c.passes_knockout_through() => push(ch, out),
_ => out.push(c),
}
}
}
let mut out = Vec::with_capacity(children.len());
push(children, &mut out);
out
}
}
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? /// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
fn keeps_gradient_fit(a: Affine) -> bool { fn keeps_gradient_fit(a: Affine) -> bool {
let [m0, m1, m2, m3, _, _] = a.as_coeffs(); let [m0, m1, m2, m3, _, _] = a.as_coeffs();
@ -657,11 +776,13 @@ mod tests {
fn default_transparency() { fn default_transparency() {
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
assert!(p().has_default_transparency()); assert!(p().has_default_transparency());
let tweaks: [fn(&mut Node); 5] = [ let tweaks: [fn(&mut Node); 7] = [
|n| n.opacity = 0.5, |n| n.opacity = 0.5,
|n| n.blend = BlendMode::Multiply, |n| n.blend = BlendMode::Multiply,
|n| n.isolate = true, |n| n.isolate = true,
|n| n.knockout = true, |n| n.knockout = Knockout::On,
|n| n.knockout = Knockout::Off,
|n| n.knockout_shape = true,
|n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))), |n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))),
]; ];
for tweak in tweaks { for tweak in tweaks {

View File

@ -105,6 +105,23 @@ impl Document {
} }
} }
impl Document {
/// Visit every paint held by the art, symbol definitions, pattern tiles and graphic styles
/// (fills, strokes and text runs), and the colour of every gradient-mesh point as a solid paint.
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
let mut tree = |n: &Node| {
n.walk(&mut |m| {
node_paints(m).for_each(&mut *f);
if let NodeKind::Mesh(mesh) = &m.kind {
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
}
})
};
self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art)).for_each(|n| tree(n));
self.graphic_styles.iter().flat_map(|gs| &gs.appearance.items).for_each(|it| f(item_paint(it)));
}
}
/// The default name of a solid swatch: its values in its own colour model ("C=10 M=20 Y=30 K=0", /// The default name of a solid swatch: its values in its own colour model ("C=10 M=20 Y=30 K=0",
/// "R=255 G=128 B=0", "Gray K=40"). /// "R=255 G=128 B=0", "Gray K=40").
pub fn color_name(c: Color) -> String { pub fn color_name(c: Color) -> String {

View File

@ -2,7 +2,8 @@
//! //!
//! SVG, PDF and the clipboard have no notion of Illustrator's live effects, so exporters receive a //! SVG, PDF and the clipboard have no notion of Illustrator's live effects, so exporters receive a
//! copy of the document in which every geometry effect — object level, per fill/stroke and //! copy of the document in which every geometry effect — object level, per fill/stroke and
//! Effect → Pathfinder — is evaluated exactly like the renderer does. Raster effects (shadows, //! Effect → Pathfinder, and those on type, images, symbol instances and live objects (through their
//! outlines, [`reshape`]) — is evaluated exactly like the renderer does. Raster effects (shadows,
//! glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters). //! glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
use std::sync::Arc; use std::sync::Arc;
@ -10,7 +11,9 @@ use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_geom::{FillRule, PathData};
use crate::{GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, pathfinder_children}; use crate::{
GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, needs_outline, pathfinder_children, reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
match item { match item {
@ -67,6 +70,15 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
if !needs_bake(n) { if !needs_bake(n) {
return None; return None;
} }
// Type, images, symbol instances and live objects: reshaped through their outlines.
if needs_outline(n) && has_geometry(&n.appearance.effects) {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
};
let m = reshape(n, symbol.as_deref())?;
return Some(bake_node(d, &m).unwrap_or(m));
}
if let Some(result) = pathfinder_children(n, None) { if let Some(result) = pathfinder_children(n, None) {
let mut m = n.clone(); let mut m = n.clone();
m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); m.appearance.effects.retain(|e| !is_pathfinder(&e.id));
@ -101,8 +113,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> { fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
let (base, rule) = geometry(n)?; let (base, rule) = geometry(n)?;
let w = n.appearance.stroke_width(); let ctx = GeomContext::of(n);
let ctx = GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } };
let g = apply(&n.appearance.effects, &base, &ctx); let g = apply(&n.appearance.effects, &base, &ctx);
let mut m = n.clone(); let mut m = n.clone();
m.appearance.effects.retain(|e| !is_geometry(&e.id)); m.appearance.effects.retain(|e| !is_geometry(&e.id));
@ -117,7 +128,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
.items .items
.iter() .iter()
.map(|item| { .map(|item| {
let ig = apply(item_effects(item), &g, &ctx); let ig = apply(item_effects(item), &g, &ctx.item(item));
let mut it = item.clone(); let mut it = item.clone();
clear_item_effects(&mut it); clear_item_effects(&mut it);
let id = d.alloc_id(); let id = d.alloc_id();
@ -129,7 +140,8 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
opacity: 1.0, opacity: 1.0,
blend: Default::default(), blend: Default::default(),
isolate: false, isolate: false,
knockout: false, knockout: Default::default(),
knockout_shape: false,
mask: None, mask: None,
trace: None, trace: None,
wrap: None, wrap: None,

View File

@ -12,6 +12,8 @@
//! renderer, the exporters and Outline Stroke. //! renderer, the exporters and Outline Stroke.
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the //! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults. //! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
//! - [`reshape`] applies geometry effects to type, images, symbol instances and live objects
//! through their outlines.
//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and //! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
//! PDF writers. //! PDF writers.
//! //!
@ -24,6 +26,7 @@ mod clip;
mod distort; mod distort;
mod group; mod group;
mod raster; mod raster;
mod reshape;
pub mod stroke; pub mod stroke;
mod stylize; mod stylize;
mod util; mod util;
@ -33,13 +36,14 @@ mod warp;
mod tests; mod tests;
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
use vectorcraft_doc::Effect; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, PathData, Rect}; use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
pub use bake::{bake_document, needs_bake}; pub use bake::{bake_document, needs_bake};
pub use clip::clip_outline; pub use clip::clip_outline;
pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children}; pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children};
pub use raster::{RasterFx, outset, raster_effects}; pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{needs_outline, outline_art, outline_text, reshape};
pub use warp::{WarpStyle, warp_point}; pub use warp::{WarpStyle, warp_point};
/// Catalogue entry for one effect. /// Catalogue entry for one effect.
@ -158,7 +162,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}", "{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}",
json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}), json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}),
), ),
g("path.outlineStroke", "Outline Stroke", PATH, "{width?: pt (defaults to the object's stroke weight)}", json!({})), g(
"path.outlineStroke",
"Outline Stroke",
PATH,
"{width?: pt (defaults to the stroke's weight)} the outline of the stroke the effect is on (on a fill or the object: the top stroke), with its caps, joins, dashes, alignment, width profile and arrowheads",
json!({}),
),
g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})), g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})),
g( g(
"stylize.scribble", "stylize.scribble",
@ -269,15 +279,33 @@ pub fn has_geometry(effects: &[Effect]) -> bool {
} }
/// Context the geometry effects may need from the object's appearance. /// Context the geometry effects may need from the object's appearance.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct GeomContext { pub struct GeomContext<'a> {
/// Stroke weight used by `path.outlineStroke` when the effect has no `width`. /// The stroke `path.outlineStroke` outlines (its weight, caps, joins, dashes, alignment,
pub stroke_width: f64, /// profile and arrowheads): the stroke the effect sits on, else the object's top painted
/// stroke. `None` outlines a plain 1 pt stroke.
pub stroke: Option<&'a StrokeLayer>,
/// The object's fill rule (inside and outside alignment).
pub rule: FillRule,
} }
impl Default for GeomContext { impl<'a> GeomContext<'a> {
fn default() -> Self { /// The context of `n`'s object-level effects.
Self { stroke_width: 1.0 } pub fn of(n: &'a Node) -> Self {
let rule = match &n.kind {
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
_ => FillRule::NonZero,
};
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule }
}
/// The context of the effects on `item`, one of the same object's appearance items: a
/// stroke's own effects outline that stroke.
pub fn item(self, item: &'a AppearanceItem) -> Self {
match item {
AppearanceItem::Stroke(s) => Self { stroke: Some(s), ..self },
AppearanceItem::Fill(_) => self,
}
} }
} }
@ -312,7 +340,7 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath() apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath()
} }
fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData { pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
use util::*; use util::*;
match id { match id {
"distort.freeDistort" => distort::free_distort(path, b, p), "distort.freeDistort" => distort::free_distort(path, b, p),
@ -330,11 +358,22 @@ fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -
vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0)) vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0))
} }
"path.outlineStroke" => { "path.outlineStroke" => {
let w = p.get("width").and_then(Value::as_f64).unwrap_or(ctx.stroke_width); let fallback;
if w <= 0.0 { let base = match ctx.stroke {
Some(s) => s,
None => {
fallback = StrokeLayer::new(vectorcraft_doc::color::Paint::None, 1.0);
&fallback
}
};
let st = match p.get("width").and_then(Value::as_f64) {
Some(w) => std::borrow::Cow::Owned(StrokeLayer { width: w, ..base.clone() }),
None => std::borrow::Cow::Borrowed(base),
};
if st.width.is_nan() || st.width <= 0.0 {
return path.clone(); return path.clone();
} }
vectorcraft_pathops::outline_stroke(path, w, vectorcraft_pathops::Cap::Butt, join(p, "joins"), num(p, "miterLimit", 10.0).max(1.0)) stroke::outline_region(path, ctx.rule, &st)
} }
"convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p), "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p),
"stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)), "stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)),

View File

@ -0,0 +1,182 @@
//! Object-level effects on objects that aren't paths: type, placed images, symbol instances and
//! live objects (blends, envelopes, meshes, repeats).
//!
//! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters.
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect, shapes};
use crate::{GeomContext, apply_one, is_geometry, merged_params};
/// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is
/// neither a path nor a container)?
pub fn needs_outline(n: &Node) -> bool {
matches!(
n.kind,
NodeKind::Text(_)
| NodeKind::Image(_)
| NodeKind::SymbolInstance { .. }
| NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
)
}
/// Type as glyph outlines in document space: one path per run with the run's fill and stroke,
/// then (when the object has its own fills/strokes) the whole outline with the object's stack,
/// painted over the characters as the renderer does.
pub fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
let mut all = kurbo::BezPath::new();
for g in &layout.glyphs {
if let Some(r) = runs.get_mut(g.run) {
r.extend(g.outline.iter());
}
all.extend(g.outline.iter());
}
let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf);
let mut children: Vec<Arc<Node>> = runs
.iter()
.zip(&t.runs)
.filter(|(bp, _)| !bp.elements().is_empty())
.map(|(bp, run)| {
let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], effects: vec![] };
if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 {
ap.items.push(AppearanceItem::Stroke(StrokeLayer::new(run.style.stroke.clone(), run.style.stroke_width)));
}
Arc::new(Node::path(n.id, path(bp), ap))
})
.collect();
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
let ap = Appearance { items: n.appearance.items.clone(), effects: vec![] };
children.push(Arc::new(Node::path(n.id, path(&all), ap)));
}
Some(Node::group(n.id, children))
}
/// `n` as plain art that geometry effects can reshape, without its transparency and object
/// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined),
/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by
/// the instance's transform. `None` for other kinds (images, paths, containers).
pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
let mut art = match &n.kind {
NodeKind::Text(_) => outline_text(n)?,
NodeKind::SymbolInstance { xf, .. } => {
let mut a = symbol_art?.clone();
a.transform(*xf, false);
Node::group(n.id, vec![Arc::new(a)])
}
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
_ => return None,
};
outline_nested_text(&mut art);
art.opacity = 1.0;
art.blend = Default::default();
art.isolate = false;
// One element: its pieces never knock each other out.
art.knockout = vectorcraft_doc::Knockout::Off;
art.mask = None;
art.appearance.effects.clear();
Some(art)
}
/// Replace type anywhere inside `n` by its outlines.
fn outline_nested_text(n: &mut Node) {
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut() {
if let Some(o) = outline_text(c) {
*c = Arc::new(o);
} else if c.children().is_some() {
outline_nested_text(Arc::make_mut(c));
}
}
}
/// `n` (see [`needs_outline`]) with its object-level geometry effects applied: its [`outline_art`]
/// reshaped as one piece, or for an image the image clipped by its reshaped frame. The result is
/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None`
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
let fx: Vec<_> = n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect();
if fx.is_empty() || !needs_outline(n) {
return None;
}
let mut art = match &n.kind {
NodeKind::Image(im) => {
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
let mut clip = Node::path(n.id, frame, Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let image = Node { appearance: Appearance::default(), ..Node::new(n.id, n.kind.clone()) };
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })
}
_ => outline_art(n, symbol_art)?,
};
for e in fx {
// Every leaf takes the effect with the whole art's bounds as its reference box.
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
for_each_leaf(&mut art, &mut |leaf| {
let Some((path, rule)) = leaf_geometry(leaf) else { return };
let out = apply_one(&e.id, &params, &path, bounds, &GeomContext::of(leaf));
set_geometry(leaf, out, rule);
});
}
// The art is a group with `n`'s id (a clip group for an image).
let mut out = art;
out.name = n.name.clone();
out.opacity = n.opacity;
out.blend = n.blend;
out.isolate = n.isolate;
// One element: its pieces never knock each other out.
out.knockout = vectorcraft_doc::Knockout::Off;
out.mask = n.mask.clone();
out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
Some(out)
}
/// Run `f` on every path and compound path under `n` (guides skipped).
fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) {
match &mut n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => f(n),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children.iter_mut() {
for_each_leaf(Arc::make_mut(c), f);
}
}
_ => {}
}
}
fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => {
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
}
_ => None,
}
}
/// Replace the geometry of path or compound `n` (a compound when `path` has several subpaths).
fn set_geometry(n: &mut Node, path: PathData, rule: FillRule) {
let clipping = matches!(n.kind, NodeKind::Path { clipping: true, .. });
n.kind = if path.subpaths.len() > 1 {
let id = n.id;
let children = path.subpaths.into_iter().map(|sp| Arc::new(Node::path(id, PathData::single(sp), Appearance::default()))).collect();
NodeKind::Compound { children, rule }
} else {
NodeKind::Path { path, rule, live: None, clipping, guide: false }
};
}

View File

@ -3,14 +3,16 @@
//! //!
//! - [`stroke_pieces`]: the centre line (trimmed under the arrowheads) and the arrowheads as //! - [`stroke_pieces`]: the centre line (trimmed under the arrowheads) and the arrowheads as
//! filled outlines (hollow kinds are rings), placed by [`ArrowAlign`](vectorcraft_doc::ArrowAlign); //! filled outlines (hollow kinds are rings), placed by [`ArrowAlign`](vectorcraft_doc::ArrowAlign);
//! - [`dash`]: the dash pattern; zero-length dashes become [`Dot`]s that a round or projecting cap //! - [`dash`]: the dash pattern, exact or fitted to corners and path ends; zero-length dashes
//! turns into discs or squares ([`dot_outline`]); //! become [`Dot`]s that a round or projecting cap turns into discs or squares ([`dot_outline`]);
//! - [`width_outline`]: variable-width (profile) strokes; //! - [`width_outline`]: variable-width (profile) strokes, with the stroke's joins at corners;
//! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots, //! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots,
//! caps and joins). //! caps and joins);
//! - [`for_writer`] and [`outline_region`]: strokes for the SVG/PDF writers and Outline Stroke.
mod arrow; mod arrow;
mod dash; mod dash;
mod outline;
mod width; mod width;
use std::borrow::Cow; use std::borrow::Cow;
@ -20,10 +22,14 @@ use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
pub use arrow::Arrow; pub use arrow::Arrow;
pub use dash::{Dashed, Dot, dash, dot_outline}; pub use dash::{Dashed, Dot, dash, dot_outline};
pub use outline::{OUTLINE_TOL, Written, WrittenShape, for_writer, is_plain, outline_region};
pub use width::width_outline; pub use width::width_outline;
/// Arc-length accuracy for dashing and trimming (document points). /// Arc-length accuracy for dashing and trimming (document points).
const ARCLEN_ACCURACY: f64 = 1e-6; const ARCLEN_ACCURACY: f64 = 1e-6;
/// Tangents meeting at more than about 1° (this cosine) make a corner: fitted dashes centre on it
/// and variable-width strokes give it the stroke's join.
const CORNER_COS: f64 = 0.9998;
/// The geometry one stroke paints, in the coordinate space of its path. /// The geometry one stroke paints, in the coordinate space of its path.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -81,34 +87,43 @@ pub fn style(st: &StrokeLayer, width: f64) -> kurbo::Stroke {
.with_miter_limit(st.miter_limit) .with_miter_limit(st.miter_limit)
} }
/// Does the dash pattern of `st` draw anything other than a solid line?
pub fn is_dashed(st: &StrokeLayer) -> bool {
st.dash.as_ref().is_some_and(|d| d.pattern.iter().any(|v| *v > 0.0))
}
/// The filled outline of the line part of `st` along `line` (usually [`StrokePieces::line`]) at /// The filled outline of the line part of `st` along `line` (usually [`StrokePieces::line`]) at
/// `width` (see [`aligned_width`]), flattened/fitted to `tol`: the width profile, or the dash /// `width` (see [`aligned_width`]), flattened/fitted to `tol`: the dash pattern with its dots and
/// pattern with its dots, with the stroke's caps and joins. Fill it with the non-zero rule. /// the width profile (each dash and dot as wide as the profile where it sits along the path), with
/// Arrowheads are not included. /// the stroke's caps and joins. Fill it with the non-zero rule. Arrowheads are not included.
pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> BezPath { pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> BezPath {
if line.elements().is_empty() || width <= 0.0 || !width.is_finite() { if line.elements().is_empty() || width <= 0.0 || !width.is_finite() {
return BezPath::new(); return BezPath::new();
} }
let tol = tol.max(1e-4); let tol = tol.max(1e-4);
if let Some(profile) = st.profile.as_ref().filter(|_| !is_dashed(st)) { let dashed = st.dash.as_ref().and_then(|d| dash(line, d));
return width_outline(line, width, profile, st.cap, tol); match (st.profile.as_ref(), dashed) {
} (Some(profile), None) => width_outline(line, width, profile, st, tol),
let style = style(st, width); (Some(profile), Some(d)) => {
match st.dash.as_ref().and_then(|d| dash(line, d)) { let mut out = width::outline_spans(&d.path, &d.spans, width, profile, st, tol);
Some(d) => { for dot in &d.dots {
let mut out = kurbo::stroke(d.path.iter(), &style, &kurbo::StrokeOpts::default(), tol); // The dot spans the profile's left and right widths there.
let (l, r) = profile.at(dot.t);
let (l, r) = (l.max(0.0), r.max(0.0));
let at = dot.at + width::left(dot.dir) * (width * (l - r) / 4.0);
out.extend(dot_outline(&[Dot { at, ..*dot }], width * (l + r) / 2.0, st.cap, tol).iter());
}
out
}
(None, Some(d)) => {
let mut out = kurbo::stroke(d.path.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol);
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter()); out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
out out
} }
None => kurbo::stroke(line.iter(), &style, &kurbo::StrokeOpts::default(), tol), (None, None) => kurbo::stroke(line.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol),
} }
} }
/// Do segments `a` and `b` (`b` following `a`) meet at a corner?
pub(crate) fn kink(a: &PathSeg, b: &PathSeg) -> bool {
tangent(a, 1.0).dot(tangent(b, 0.0)) < CORNER_COS
}
/// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point. /// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point.
pub(crate) fn tangent(s: &PathSeg, t: f64) -> Vec2 { pub(crate) fn tangent(s: &PathSeg, t: f64) -> Vec2 {
let d = match s { let d = match s {
@ -180,3 +195,5 @@ pub(crate) fn segments(els: &[PathEl]) -> Vec<PathSeg> {
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
#[cfg(test)]
mod tests_fit;

View File

@ -1,29 +1,43 @@
//! Dash patterns. Dashes follow the path's arc length, restart on every subpath and join across //! Dash patterns. Dashes follow the path's arc length, restart on every subpath and join across
//! the start of a closed subpath. A dash of zero length is a [`Dot`]: with a round cap it paints //! the start of a closed subpath. A dash of zero length is a [`Dot`]: with a round cap it paints
//! a disc, with a projecting cap a square turned along the path, with a butt cap nothing. //! a disc, with a projecting cap a square turned along the path, with a butt cap nothing.
//!
//! Exact dashes keep the pattern's lengths and start `offset` into it. Dashes fitted to corners
//! ([`Dash::align_corners`]) ignore the offset: every run between corners and path ends holds a
//! whole number of periods, stretched or squeezed to fit, starting and ending in the middle of the
//! first dash, so a dash is centred on every corner and on both ends of an open path.
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2}; use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
use vectorcraft_doc::{Dash, LineCap}; use vectorcraft_doc::{Dash, LineCap};
use super::{ARCLEN_ACCURACY, push_seg, segments, subpaths, tangent}; use super::{ARCLEN_ACCURACY, kink, push_seg, segments, subpaths, tangent};
/// More dashes than this (a tiny pattern on a long path) draws the line solid instead. /// More dashes than this (a tiny pattern on a long path) draws the line solid instead.
const MAX_DASHES: f64 = 200_000.0; const MAX_DASHES: f64 = 200_000.0;
/// A pattern whose longest entry is shorter than this can't advance along the path (each entry /// A pattern whose longest entry is shorter than this can't advance along the path (each entry
/// ends within the generator's tolerance of where it starts), so it draws the line solid. /// ends within the generator's tolerance of where it starts), so it draws the line solid.
const MIN_ENTRY: f64 = 1e-6; const MIN_ENTRY: f64 = 1e-6;
/// A run between corners shorter than this keeps the pattern's own lengths (too short to fit).
const MIN_RUN: f64 = 1e-6;
/// Arc-length tolerance of the generator.
const EPS: f64 = 1e-9;
/// A zero-length dash: where it sits and the path's direction there. /// A zero-length dash: where it sits and the path's direction there.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct Dot { pub struct Dot {
pub at: Point, pub at: Point,
pub dir: Vec2, pub dir: Vec2,
/// Fraction of its subpath's length at the dot (where a width profile is read).
pub t: f64,
} }
/// A dashed path: the dashes (open subpaths to stroke without a dash pattern) and the dots. /// A dashed path: the dashes (open subpaths to stroke without a dash pattern) and the dots.
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct Dashed { pub struct Dashed {
pub path: BezPath, pub path: BezPath,
/// For each dash (subpath of `path`, in order), the fractions of its subpath's length where it
/// starts and ends. The end passes 1 for a dash running on through the start of a closed path.
pub spans: Vec<(f64, f64)>,
pub dots: Vec<Dot>, pub dots: Vec<Dot>,
} }
@ -43,13 +57,18 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option<Dashed> {
pat.extend_from_within(..); pat.extend_from_within(..);
} }
let period: f64 = pat.iter().sum(); let period: f64 = pat.iter().sum();
let subs: Vec<(Vec<PathSeg>, bool)> = subpaths(bp).into_iter().map(|(r, closed)| (segments(&bp.elements()[r]), closed)).collect(); let plans: Vec<Plan> = subpaths(bp)
let lens: Vec<Vec<f64>> = subs.iter().map(|(segs, _)| segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect()).collect(); .into_iter()
let total: f64 = lens.iter().flatten().sum(); .map(|(r, closed)| {
if total / period * pat.len() as f64 > MAX_DASHES { let segs = segments(&bp.elements()[r]);
let lens = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect();
if d.align_corners { Plan::fitted(segs, lens, closed, period) } else { Plan::exact(segs, lens, closed, period) }
})
.collect();
if plans.iter().map(|p| p.periods).sum::<f64>() * pat.len() as f64 > MAX_DASHES {
return None; return None;
} }
// Where the pattern starts: `offset` into it. // Where an exact pattern starts: `offset` into it.
let mut off = d.offset.rem_euclid(period); let mut off = d.offset.rem_euclid(period);
let mut ix0 = 0; let mut ix0 = 0;
while off > pat[ix0] { while off > pat[ix0] {
@ -58,12 +77,75 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option<Dashed> {
} }
let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 }; let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 };
let mut out = Dashed::default(); let mut out = Dashed::default();
for ((segs, closed), lens) in subs.iter().zip(&lens) { for plan in &plans {
dash_subpath(&mut out, segs, lens, *closed, &pat, phase); dash_subpath(&mut out, plan, &pat, phase);
} }
Some(out) Some(out)
} }
/// How the pattern runs along one subpath.
struct Plan {
segs: Vec<PathSeg>,
lens: Vec<f64>,
closed: bool,
/// The pattern's scale on each segment (1 for exact dashes).
scale: Vec<f64>,
/// Segments that start a run between corners: the pattern restarts there in the middle of its
/// first dash (fitted dashes only).
restart: Vec<bool>,
/// Arc length from the subpath's start to the start of `segs` (a fitted closed subpath starts
/// at its first corner).
shift: f64,
/// Periods of the pattern drawn.
periods: f64,
}
impl Plan {
fn exact(segs: Vec<PathSeg>, lens: Vec<f64>, closed: bool, period: f64) -> Self {
let n = segs.len();
let periods = lens.iter().sum::<f64>() / period;
Plan { segs, lens, closed, scale: vec![1.0; n], restart: vec![false; n], shift: 0.0, periods }
}
fn fitted(mut segs: Vec<PathSeg>, mut lens: Vec<f64>, closed: bool, period: f64) -> Self {
let n = segs.len();
// Corners: where segments with length meet at a kink (zero-length ones have no direction).
let live: Vec<usize> = (0..n).filter(|&i| lens[i] > EPS).collect();
let mut corners: Vec<usize> = live.windows(2).filter(|w| kink(&segs[w[0]], &segs[w[1]])).map(|w| w[1]).collect();
let mut rot = 0;
if closed && let (Some(&first), Some(&last)) = (live.first(), live.last()) {
if kink(&segs[last], &segs[first]) {
corners.insert(0, first);
}
// Start at a corner so that no run crosses the start point.
rot = corners.first().copied().unwrap_or(0);
}
segs.rotate_left(rot);
lens.rotate_left(rot);
let shift = lens[n - rot..].iter().sum();
let mut restart = vec![false; n];
restart[0] = true;
for c in corners {
restart[(c + n - rot) % n] = true;
}
let (mut scale, mut periods) = (vec![1.0; n], 0.0);
let mut i = 0;
while i < n {
let j = (i + 1..n).find(|&j| restart[j]).unwrap_or(n);
let run: f64 = lens[i..j].iter().sum();
if run > MIN_RUN {
let k = (run / period).round().max(1.0);
scale[i..j].fill(run / (k * period));
periods += k;
} else {
periods += run / period;
}
i = j;
}
Plan { segs, lens, closed, scale, restart, shift, periods }
}
}
/// Position in the pattern: entry index, length left in it, and whether it is a dash. /// Position in the pattern: entry index, length left in it, and whether it is a dash.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct Phase { struct Phase {
@ -72,20 +154,56 @@ struct Phase {
on: bool, on: bool,
} }
fn dash_subpath(out: &mut Dashed, segs: &[PathSeg], lens: &[f64], closed: bool, pat: &[f64], mut ph: Phase) { /// A dash being collected: its segments and where it starts and ends along its plan.
const EPS: f64 = 1e-9; #[derive(Default)]
struct Piece {
segs: Vec<PathSeg>,
start: f64,
end: f64,
}
impl Piece {
fn push(&mut self, seg: PathSeg, at: f64) {
if self.segs.is_empty() {
self.start = at;
}
self.segs.push(seg);
}
}
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
let first_dot = out.dots.len(); let first_dot = out.dots.len();
let starts_on = ph.on && ph.rem > 0.0; let total: f64 = plan.lens.iter().sum();
let mut dashes: Vec<Vec<PathSeg>> = vec![]; // Fraction of the subpath's length at `x` along the plan.
let mut cur: Vec<PathSeg> = vec![]; let frac = |x: f64| {
let mut cuts = 0; if total <= 0.0 {
for (seg, &len) in segs.iter().zip(lens) { return 0.0;
}
let f = (x + plan.shift) / total;
if f > 1.0 { f - 1.0 } else { f }
};
let mut dashes: Vec<Piece> = vec![];
let mut cur = Piece::default();
let (mut cuts, mut acc, mut starts_on) = (0, 0.0, false);
for (i, (seg, &len)) in plan.segs.iter().zip(&plan.lens).enumerate() {
let k = plan.scale[i];
if plan.restart[i] {
// The dash running into a corner goes on past it; a dot there ends it.
if pat[0] <= 0.0 && !cur.segs.is_empty() {
cur.end = acc;
dashes.push(std::mem::take(&mut cur));
}
ph = Phase { ix: 0, rem: pat[0] * k / 2.0, on: true };
}
if i == 0 {
starts_on = ph.on && ph.rem > 0.0;
}
let mut s = 0.0; let mut s = 0.0;
loop { loop {
let left = len - s; let left = len - s;
if ph.rem > left + EPS { if ph.rem > left + EPS {
if ph.on && left > EPS { if ph.on && left > EPS {
cur.push(sub(seg, len, s, len)); cur.push(sub(seg, len, s, len), acc + s);
} }
ph.rem -= left; ph.rem -= left;
break; break;
@ -94,40 +212,52 @@ fn dash_subpath(out: &mut Dashed, segs: &[PathSeg], lens: &[f64], closed: bool,
if ph.on { if ph.on {
if pat[ph.ix] > 0.0 { if pat[ph.ix] > 0.0 {
if end > s { if end > s {
cur.push(sub(seg, len, s, end)); cur.push(sub(seg, len, s, end), acc + s);
} }
if !cur.is_empty() { if !cur.segs.is_empty() {
cur.end = acc + end;
dashes.push(std::mem::take(&mut cur)); dashes.push(std::mem::take(&mut cur));
} }
} else { } else {
let t = param(seg, len, end); let t = param(seg, len, end);
out.dots.push(Dot { at: seg.eval(t), dir: tangent(seg, t) }); push_dot(out, first_dot, Dot { at: seg.eval(t), dir: tangent(seg, t), t: frac(acc + end) });
} }
} }
cuts += 1; cuts += 1;
s = end; s = end;
ph.ix = (ph.ix + 1) % pat.len(); ph.ix = (ph.ix + 1) % pat.len();
ph.rem = pat[ph.ix]; ph.rem = pat[ph.ix] * k;
ph.on = !ph.on; ph.on = !ph.on;
} }
acc += len;
} }
let ends_on = !cur.is_empty(); // A fitted open path whose pattern starts with a dot ends on one, whatever the rounding.
if !plan.closed
&& plan.restart[0]
&& pat[0] <= 0.0
&& let Some(seg) = plan.segs.last()
{
push_dot(out, first_dot, Dot { at: seg.end(), dir: tangent(seg, 1.0), t: 1.0 });
}
let ends_on = !cur.segs.is_empty();
if ends_on { if ends_on {
cur.end = total;
dashes.push(cur); dashes.push(cur);
} }
if closed { if plan.closed {
if cuts == 0 && ends_on { if cuts == 0 && ends_on {
// One dash all the way round: keep it closed so the start gets a join, not caps. // One dash all the way round: keep it closed so the start gets a join, not caps.
let segs = dashes.pop().unwrap_or_default(); let d = dashes.pop().unwrap_or_default();
write(&mut out.path, &segs); write(&mut out.path, &d.segs);
out.path.close_path(); out.path.close_path();
out.spans.push((frac(0.0), frac(0.0) + 1.0));
return; return;
} }
if starts_on && ends_on && dashes.len() > 1 { if starts_on && ends_on && dashes.len() > 1 {
// The dash running through the start point is one dash. // The dash running through the start point is one dash.
let mut last = dashes.pop().unwrap_or_default(); let mut last = dashes.pop().unwrap_or_default();
last.append(&mut dashes[0]); last.segs.append(&mut dashes[0].segs);
dashes[0] = last; dashes[0] = Piece { segs: last.segs, start: last.start, end: dashes[0].end + total };
} }
// A dot on the start point is reached again at the end. // A dot on the start point is reached again at the end.
if out.dots.len() > first_dot + 1 && out.dots[first_dot].at.distance(out.dots[out.dots.len() - 1].at) < 1e-6 { if out.dots.len() > first_dot + 1 && out.dots[first_dot].at.distance(out.dots[out.dots.len() - 1].at) < 1e-6 {
@ -135,7 +265,17 @@ fn dash_subpath(out: &mut Dashed, segs: &[PathSeg], lens: &[f64], closed: bool,
} }
} }
for d in &dashes { for d in &dashes {
write(&mut out.path, d); write(&mut out.path, &d.segs);
let t0 = frac(d.start);
out.spans.push((t0, if total > 0.0 { t0 + (d.end - d.start) / total } else { t0 }));
}
}
/// Add `dot` unless the subpath's last dot (from index `first`) is already there: a run ending on
/// a dot meets the next one starting on it.
fn push_dot(out: &mut Dashed, first: usize, dot: Dot) {
if out.dots[first..].last().is_none_or(|d| d.at.distance(dot.at) > 1e-6) {
out.dots.push(dot);
} }
} }

View File

@ -0,0 +1,116 @@
//! Strokes as filled geometry outside the canvas:
//!
//! - [`for_writer`]: what a file format whose strokes only have a weight, caps, joins, a miter
//! limit and a dash array (SVG, PDF) writes so a stroke looks as it does on the canvas: a plain
//! stroke where that is exact, else the canvas's filled outlines (arrowheads, width profiles,
//! dashes fitted to corners, dots);
//! - [`outline_region`]: the area a stroke paints as one clean path (Outline Stroke and the live
//! Outline Stroke effect).
use kurbo::{BezPath, Rect, Shape};
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_pathops::BoolOp;
use super::{StrokePieces, aligned_width, is_closed, line_outline, stroke_pieces};
/// Flattening/fitting tolerance of written and outlined strokes (document points).
pub const OUTLINE_TOL: f64 = 0.01;
/// What a writer draws for one stroke (in the coordinate space of its path).
#[derive(Clone, Debug)]
pub struct Written {
pub shape: WrittenShape,
/// `Some` for an inside or outside stroke of a closed path: the shape is drawn twice as wide
/// and clipped to the path's interior (inside) or has the interior removed (outside).
pub side: Option<StrokeAlign>,
}
/// The geometry of a [`Written`] stroke.
#[derive(Clone, Debug)]
pub enum WrittenShape {
/// A plain stroke of the path itself at `width`, with the layer's caps, joins, miter limit
/// and dash pattern.
Stroke { width: f64 },
/// Filled outlines (non-zero rule): the line, then each arrowhead. They overlap, so they take
/// the stroke's opacity once, as a group.
Fill(Vec<BezPath>),
}
/// Does `st` draw as a plain centred stroke on every path (no alignment, arrowheads, width
/// profile, brush, fitted dashes or dots)? Writers then need no geometry for it.
pub fn is_plain(st: &StrokeLayer) -> bool {
st.align == StrokeAlign::Center
&& st.start_arrow.is_none()
&& st.end_arrow.is_none()
&& st.profile.is_none()
&& st.brush.is_none()
&& !needs_outline_dashes(st)
}
/// Dashes a plain stroke can't draw like the canvas: fitted to corners, or zero-length dashes
/// (dots) with a cap that paints them.
fn needs_outline_dashes(st: &StrokeLayer) -> bool {
st.dash.as_ref().is_some_and(|d| d.is_dashed() && (d.align_corners || (st.cap != LineCap::Butt && d.pattern.contains(&0.0))))
}
/// Do these pieces of `st` need filled outlines (see [`is_plain`])? Alignment alone doesn't.
fn needs_outline(pieces: &StrokePieces, st: &StrokeLayer) -> bool {
!pieces.heads.is_empty() || st.profile.is_some() || needs_outline_dashes(st)
}
/// How a writer draws stroke `st` along `bp` (brushes aside) to match the canvas.
pub fn for_writer(bp: &BezPath, st: &StrokeLayer) -> Written {
if is_plain(st) {
return Written { shape: WrittenShape::Stroke { width: st.width }, side: None };
}
let closed = is_closed(bp);
let side = (st.align != StrokeAlign::Center && closed).then_some(st.align);
let width = aligned_width(st, closed);
let pieces = stroke_pieces(bp, st);
let shape = if needs_outline(&pieces, st) {
let mut fills = vec![line_outline(&pieces.line, st, width, OUTLINE_TOL)];
fills.extend(pieces.heads.into_iter().map(|h| h.outline));
fills.retain(|f| !f.elements().is_empty());
WrittenShape::Fill(fills)
} else {
WrittenShape::Stroke { width }
};
Written { shape, side }
}
impl Written {
/// Bounds of what is drawn before the alignment clip, for a path with bounds `path_bounds`
/// (covers miter spikes and projecting caps).
pub fn reach(&self, st: &StrokeLayer, path_bounds: Rect) -> Rect {
match &self.shape {
WrittenShape::Stroke { width } => {
let miter = if st.join == LineJoin::Miter { st.miter_limit.max(1.0) } else { 1.0 };
let cap = if st.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 };
let r = width / 2.0 * miter.max(cap);
path_bounds.inflate(r, r)
}
WrittenShape::Fill(fills) => fills.iter().map(Shape::bounding_box).fold(path_bounds, |a, b| a.union(b)),
}
}
}
/// The area stroke `st` paints along `path` (fill rule `rule`) as one clean path, as on the
/// canvas: the line (width profile, dashes, caps, joins) united with the arrowheads, and kept
/// inside or outside a closed path for inside or outside alignment. Brushes are not drawn.
pub fn outline_region(path: &PathData, rule: FillRule, st: &StrokeLayer) -> PathData {
if !(st.width > 0.0 && st.width.is_finite()) || path.is_empty() {
return PathData::default();
}
let bp = path.to_bezpath();
let closed = is_closed(&bp);
let pieces = stroke_pieces(&bp, st);
let mut parts = vec![line_outline(&pieces.line, st, aligned_width(st, closed), OUTLINE_TOL)];
parts.extend(pieces.heads.into_iter().map(|h| h.outline));
let region = vectorcraft_pathops::stroke_region(&parts);
match st.align {
StrokeAlign::Inside if closed => vectorcraft_pathops::boolean(&region, FillRule::NonZero, path, rule, BoolOp::Intersect),
StrokeAlign::Outside if closed => vectorcraft_pathops::boolean(&region, FillRule::NonZero, path, rule, BoolOp::Difference),
_ => region,
}
}

View File

@ -90,7 +90,7 @@ fn dashes_follow_arc_length_and_join_across_the_start_of_closed_paths() {
#[test] #[test]
fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() { fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() {
let dots = [Dot { at: Point::new(10.0, 0.0), dir: Vec2::new(1.0, 0.0) }]; let dots = [Dot { at: Point::new(10.0, 0.0), dir: Vec2::new(1.0, 0.0), t: 0.0 }];
let round = dot_outline(&dots, 4.0, LineCap::Round, 1e-4); let round = dot_outline(&dots, 4.0, LineCap::Round, 1e-4);
assert!(near(round.area(), std::f64::consts::PI * 4.0, 1e-3)); assert!(near(round.area(), std::f64::consts::PI * 4.0, 1e-3));
let sq = dot_outline(&dots, 4.0, LineCap::Square, 1e-4); let sq = dot_outline(&dots, 4.0, LineCap::Square, 1e-4);
@ -117,12 +117,13 @@ fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
assert!(near(plain.area().abs(), 1000.0, 1e-6)); assert!(near(plain.area().abs(), 1000.0, 1e-6));
let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3); let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3);
assert!(near(lens.area().abs(), 500.0, 1e-6)); assert!(near(lens.area().abs(), 500.0, 1e-6));
// Dashes take precedence over a profile (as before). // Each dash takes its widths from where it sits along the lens (0 → 1 → 0): the dash over
// 0..25 averages a quarter of the width, the one over 50..75 three quarters.
let st = stroke(10.0, |s| { let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens()); s.profile = Some(WidthProfile::lens());
s.dash = Some(dashed(&[25.0, 25.0], 0.0)); s.dash = Some(dashed(&[25.0, 25.0], 0.0));
}); });
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0, 1e-6)); assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 62.5 + 187.5, 1e-6));
assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty()); assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty());
} }

View File

@ -0,0 +1,224 @@
//! Dashes fitted to corners and path ends, and variable-width strokes with dashes and joins.
use kurbo::{BezPath, ParamCurveArclen, PathEl, Point, Rect, Shape};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Dash, LineCap, LineJoin, StrokeLayer, WidthProfile};
use super::*;
fn fitted(pattern: &[f64], offset: f64) -> Dash {
Dash { pattern: pattern.to_vec(), offset, align_corners: true }
}
fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
st
}
fn line(x1: f64) -> BezPath {
let mut b = BezPath::new();
b.move_to((0.0, 0.0));
b.line_to((x1, 0.0));
b
}
fn moves(bp: &BezPath) -> usize {
bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count()
}
fn length(bp: &BezPath) -> f64 {
bp.segments().map(|s| s.arclen(1e-9)).sum()
}
fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
// ---------------------------------------------------------------- dashes fitted to corners
#[test]
fn every_corner_of_a_rectangle_falls_mid_dash() {
// 100 × 50 with 12/6: the long sides hold 6 periods (scaled by 100/108), the short ones 3
// (50/54), so every half dash at a corner is 6 · 100/108 long.
let r = Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
let d = dash(&r, &fitted(&[12.0, 6.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 5 + 2 + 5 + 2 + 4, "inner dashes of each side plus one per corner");
assert!(near(length(&d.path), 200.0, 1e-6), "two thirds of the perimeter");
let half = 6.0 * 100.0 / 108.0;
let o = line_outline(&r, &stroke(2.0, |s| s.dash = Some(fitted(&[12.0, 6.0], 0.0))), 2.0, 1e-3);
for (c, along) in [
((0.0, 0.0), [(1.0, 0.0), (0.0, 1.0)]),
((100.0, 0.0), [(-1.0, 0.0), (0.0, 1.0)]),
((100.0, 50.0), [(-1.0, 0.0), (0.0, -1.0)]),
((0.0, 50.0), [(1.0, 0.0), (0.0, -1.0)]),
] {
let c = Point::from(c);
assert!(o.contains(c), "corner {c:?} is painted");
for v in along {
let v = kurbo::Vec2::from(v);
assert!(o.contains(c + v * (half - 0.3)), "dash runs on from {c:?} along {v:?}");
assert!(!o.contains(c + v * (half + 0.3)), "and stops half a dash from {c:?} along {v:?}");
}
}
// The spans of the dashes cover the same length.
let covered: f64 = d.spans.iter().map(|(a, b)| b - a).sum();
assert!(near(covered * 300.0, 200.0, 1e-6), "{:?}", d.spans);
}
#[test]
fn open_paths_start_and_end_mid_dash_and_runs_hold_whole_periods() {
// 100 long, 10/10: five periods, half a dash at each end.
let d = dash(&line(100.0), &fitted(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 6);
assert!(near(length(&d.path), 50.0, 1e-6));
let ends: Vec<f64> = d.path.elements().iter().filter_map(|e| e.end_point()).map(|p| p.x).collect();
assert!(near(ends[0], 0.0, 1e-9) && near(ends[1], 5.0, 1e-9) && near(*ends.last().unwrap(), 100.0, 1e-9), "{ends:?}");
// 92 long: 4.6 periods round to 5, squeezed to fit.
let d = dash(&line(92.0), &fitted(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 6);
assert!(near(length(&d.path), 46.0, 1e-6));
// A run shorter than a period still gets one: half dashes at its ends.
let d = dash(&line(4.0), &fitted(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 2);
assert!(near(length(&d.path), 2.0, 1e-6));
}
#[test]
fn fitted_dashes_ignore_the_offset_and_keep_exact_dashes_unchanged() {
let r = Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
let a = dash(&r, &fitted(&[12.0, 6.0], 0.0)).unwrap();
let b = dash(&r, &fitted(&[12.0, 6.0], 5.0)).unwrap();
assert_eq!(a.path, b.path);
// Exact dashes still start at the path's start, `offset` into the pattern.
let exact = dash(&line(100.0), &Dash { pattern: vec![10.0, 10.0], offset: 5.0, align_corners: false }).unwrap();
let first: Vec<f64> = exact.path.elements().iter().take(2).filter_map(|e| e.end_point()).map(|p| p.x).collect();
assert_eq!(first, vec![0.0, 5.0]);
assert_eq!(moves(&exact.path), 6);
}
#[test]
fn smooth_closed_paths_hold_a_whole_number_of_dashes() {
// A circle has no corners: one run all the way round, 17 periods of 12/6 (314 / 18 ≈ 17.5).
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-6);
let d = dash(&c, &fitted(&[12.0, 6.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 17, "the dash on the start point is one dash");
let circumference = 2.0 * std::f64::consts::PI * 50.0;
assert!(near(length(&d.path), circumference * 12.0 / 18.0, 1e-3));
}
#[test]
fn fitted_dots_sit_on_every_corner_and_end_once() {
// 60 pt sides with a dot every 7: 9 periods a side (60 / 63 scale), the corner dots shared.
let sq = Rect::new(0.0, 0.0, 60.0, 60.0).to_path(0.1);
let d = dash(&sq, &fitted(&[0.0, 7.0], 0.0)).unwrap();
assert_eq!(d.dots.len(), 36);
for c in [(0.0, 0.0), (60.0, 0.0), (60.0, 60.0), (0.0, 60.0)] {
assert_eq!(d.dots.iter().filter(|p| p.at.distance(Point::from(c)) < 1e-6).count(), 1, "one dot on {c:?}");
}
let d = dash(&line(60.0), &fitted(&[0.0, 7.0], 0.0)).unwrap();
assert_eq!(d.dots.len(), 10);
assert!(near(d.dots[0].at.x, 0.0, 1e-9) && near(d.dots[9].at.x, 60.0, 1e-9));
assert!(near(d.dots[9].t, 1.0, 1e-9));
}
#[test]
fn fitted_dashes_on_curves_and_degenerate_paths_stay_finite() {
let mut mixed = BezPath::new();
mixed.move_to((0.0, 0.0));
mixed.line_to((0.0, 0.0));
mixed.curve_to((30.0, -40.0), (70.0, -40.0), (100.0, 0.0));
mixed.line_to((100.0, 0.0));
mixed.line_to((50.0, 60.0));
mixed.close_path();
mixed.move_to((200.0, 0.0));
mixed.line_to((200.0, 1e-7));
for pat in [&[12.0, 6.0][..], &[0.0, 5.0], &[3.0], &[5.0, 0.0], &[1e-12, 4.0, 0.0, 2.0]] {
for profile in [None, Some(WidthProfile::lens())] {
let st = stroke(4.0, |s| {
s.dash = Some(fitted(pat, 0.0));
s.cap = LineCap::Round;
s.profile = profile.clone();
});
let o = line_outline(&mixed, &st, 4.0, 0.01);
assert!(o.elements().iter().filter_map(|e| e.end_point()).all(|p| p.x.is_finite() && p.y.is_finite()), "{pat:?}");
}
}
}
// ---------------------------------------------------------------- width profiles with dashes and joins
#[test]
fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() {
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false });
});
let o = line_outline(&line(100.0), &st, 10.0, 1e-3);
// The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip.
assert!(o.contains(Point::new(45.0, 4.0)) && o.contains(Point::new(45.0, -4.0)), "middle dash is wide");
assert!(!o.contains(Point::new(5.0, 2.0)) && o.contains(Point::new(5.0, 0.3)), "first dash is thin");
assert!(!o.contains(Point::new(15.0, 0.0)), "gaps stay empty");
// Dots too: a round dot near the middle is bigger than one near an end.
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.cap = LineCap::Round;
s.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false });
});
let o = line_outline(&line(100.0), &st, 10.0, 1e-3);
assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.5)));
}
/// An L whose corner turns 90° at (100, 0), or a sharp V turning back at (100, 0).
fn bend(sharp: bool) -> BezPath {
let mut b = line(100.0);
b.line_to(if sharp { (0.0, 20.0) } else { (100.0, 100.0) });
b
}
fn uniform(join: LineJoin, miter_limit: f64) -> StrokeLayer {
stroke(20.0, |s| {
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] });
s.join = join;
s.miter_limit = miter_limit;
})
}
#[test]
fn profile_corners_take_the_stroke_join() {
let out = |join, limit, sharp| line_outline(&bend(sharp), &uniform(join, limit), 20.0, 1e-3);
// The L: a miter fills the outer corner, a round join leaves it, a bevel cuts it.
let corner = Point::new(109.0, -9.0);
assert!(out(LineJoin::Miter, 10.0, false).contains(corner));
assert!(!out(LineJoin::Round, 10.0, false).contains(corner));
assert!(out(LineJoin::Round, 10.0, false).contains(Point::new(106.5, -6.5)), "round join reaches 10 out");
assert!(!out(LineJoin::Bevel, 10.0, false).contains(Point::new(106.5, -6.5)));
assert!(out(LineJoin::Bevel, 10.0, false).contains(Point::new(104.5, -4.5)));
// The inside of the L is filled up to the inner miter and no further.
let l = out(LineJoin::Round, 10.0, false);
assert!(l.contains(Point::new(91.0, 9.0)) && !l.contains(Point::new(89.0, 11.0)));
// The sharp V: its miter (about 10 × the half width) is past the limit of 10, so it bevels;
// no join spikes out past the round join's reach.
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
let x1 = out(join, 10.0, true).bounding_box().x1;
assert!(x1 <= 110.0 + 1e-6, "{join:?}: {x1}");
}
// A limit above it keeps the full miter.
let x1 = out(LineJoin::Miter, 20.0, true).bounding_box().x1;
assert!(x1 > 195.0, "{x1}");
}
#[test]
fn smooth_points_of_curves_stay_smooth_with_any_join() {
// A circle stroked with a uniform profile is the same ring whatever the join.
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-4);
let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0;
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] });
s.join = join;
});
let a = line_outline(&c, &st, 10.0, 1e-3).area().abs();
assert!((a - want).abs() / want < 0.005, "{join:?}: {a} vs {want}");
}
}

View File

@ -2,150 +2,233 @@
//! //!
//! A stroke with a [`WidthProfile`] is drawn as a filled outline: each subpath is flattened, and //! A stroke with a [`WidthProfile`] is drawn as a filled outline: each subpath is flattened, and
//! at every sample the left and right offsets `width/2 · factor(t)` (t = fraction of the //! at every sample the left and right offsets `width/2 · factor(t)` (t = fraction of the
//! subpath's length) are placed along the (mitred) normal. Open subpaths get butt, round or //! subpath's length) are placed along the normal. Corners of the path take the stroke's miter,
//! projecting caps (the round cap blends the two side widths); closed subpaths become two loops //! round or bevel join on their outer side (smooth points bend round), open subpaths get butt,
//! of opposite orientation filled with the non-zero rule. //! round or projecting caps (the round cap blends the two side widths), and closed subpaths become
//! two loops of opposite orientation filled with the non-zero rule. Dashes are pieces of a longer
//! subpath and read the profile where they sit along it ([`outline_spans`]).
use kurbo::{BezPath, PathEl, Point, Vec2}; use std::f64::consts::PI;
use vectorcraft_doc::{LineCap, WidthProfile};
use super::unit; use kurbo::{BezPath, ParamCurve, PathEl, PathSeg, Point, Vec2};
use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile};
/// Filled outline of `bp` stroked with `width` scaled by `profile`, flattened to `tol`. use super::{kink, segments, subpaths, unit};
pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, cap: LineCap, tol: f64) -> BezPath {
let mut out = BezPath::new(); /// Filled outline of `bp` stroked with `width` scaled by `profile`, with the caps, joins and miter
for (pts, closed) in subpaths(bp, tol.max(1e-4)) { /// limit of `st`, flattened to `tol`.
outline_subpath(&mut out, &pts, closed, width, profile, cap); pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64) -> BezPath {
} outline_spans(bp, &[], width, profile, st, tol)
out
} }
/// Flattened subpaths (consecutive duplicates removed; closed ones don't repeat the start). /// [`width_outline`] of pieces of longer subpaths: subpath `i` of `bp` covers the fractions
fn subpaths(bp: &BezPath, tol: f64) -> Vec<(Vec<Point>, bool)> { /// `spans[i]` of the subpath it was cut from (see [`Dashed::spans`](super::Dashed::spans));
let mut out: Vec<(Vec<Point>, bool)> = vec![]; /// subpaths without a span are whole.
let mut cur: Vec<Point> = vec![]; pub(super) fn outline_spans(bp: &BezPath, spans: &[(f64, f64)], width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64) -> BezPath {
let push = |cur: &mut Vec<Point>, p: Point| { let ctx = Ctx { half: width / 2.0, profile, cap: st.cap, join: st.join, miter_limit: st.miter_limit, tol: tol.max(1e-4) };
if cur.last().is_none_or(|l| l.distance(p) > 1e-9) { let mut out = BezPath::new();
cur.push(p); for (i, (r, closed)) in subpaths(bp).into_iter().enumerate() {
let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol);
if pts.len() > 1 {
ctx.subpath(&mut out, &pts, &corner, closed, spans.get(i).copied().unwrap_or((0.0, 1.0)));
} }
};
kurbo::flatten(bp.iter(), tol, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push((std::mem::take(&mut cur), false));
}
cur.clear();
cur.push(p);
}
PathEl::LineTo(p) => push(&mut cur, p),
PathEl::ClosePath => {
if cur.len() > 1 && cur.first().unwrap().distance(*cur.last().unwrap()) <= 1e-9 {
cur.pop();
}
if cur.len() > 1 {
let start = cur[0];
out.push((std::mem::take(&mut cur), true));
cur.push(start);
} else {
cur.clear();
}
}
_ => {}
});
if cur.len() > 1 {
out.push((cur, false));
} }
out out
} }
/// Left normal in y-down space (left of the direction of travel). /// Left normal in y-down space (left of the direction of travel).
fn left(t: Vec2) -> Vec2 { pub(super) fn left(t: Vec2) -> Vec2 {
Vec2::new(t.y, -t.x) Vec2::new(t.y, -t.x)
} }
/// The flattened points of one subpath (consecutive duplicates removed; a closed one doesn't
/// repeat its start), each marked when it is a corner of the path.
fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec<Point>, Vec<bool>) {
let Some(s0) = segs.first() else { return (vec![], vec![]) };
let (mut pts, mut corner) = (vec![s0.start()], vec![false]);
// The first and the last segment that drew something.
let (mut first, mut prev): (Option<&PathSeg>, Option<&PathSeg>) = (None, None);
for seg in segs {
let at = pts.len() - 1;
let mut push = |p: Point| {
if pts.last().is_none_or(|l| l.distance(p) > 1e-9) {
pts.push(p);
corner.push(false);
}
};
match seg {
PathSeg::Line(l) => push(l.p1),
_ => kurbo::flatten([PathEl::MoveTo(seg.start()), seg.as_path_el()], tol, |el| {
if let PathEl::LineTo(p) = el {
push(p);
}
}),
}
if pts.len() > at + 1 {
match prev {
Some(p) => corner[at] = kink(p, seg),
None => first = Some(seg),
}
prev = Some(seg);
}
}
if closed {
if pts.len() > 1 && pts[0].distance(pts[pts.len() - 1]) <= 1e-9 {
pts.pop();
corner.pop();
}
if let (Some(a), Some(b)) = (prev, first) {
corner[0] = kink(a, b);
}
}
(pts, corner)
}
/// Insert samples where the profile has width points (so the piecewise-linear profile is exact /// Insert samples where the profile has width points (so the piecewise-linear profile is exact
/// along straight runs). /// along straight runs). `span` maps the subpath onto the profile (see [`outline_spans`]).
fn densify(pts: &[Point], closed: bool, profile: &WidthProfile) -> Vec<Point> { fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec<Point>, Vec<bool>) {
let n = pts.len(); let n = pts.len();
let seg_count = if closed { n } else { n - 1 }; let seg_count = if closed { n } else { n - 1 };
let total: f64 = (0..seg_count).map(|i| pts[i].distance(pts[(i + 1) % n])).sum(); let total: f64 = (0..seg_count).map(|i| pts[i].distance(pts[(i + 1) % n])).sum();
if total <= 1e-12 { let range = span.1 - span.0;
return pts.to_vec(); if total <= 1e-12 || range <= 1e-12 {
return (pts.to_vec(), corner.to_vec());
} }
let mut out = Vec::with_capacity(n + profile.points.len()); // Where the profile's points fall along this piece (a span past 1 wraps round a closed path).
let marks: Vec<f64> = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(|t| (t - span.0) / range * total).collect();
let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len()));
let mut acc = 0.0; let mut acc = 0.0;
for i in 0..seg_count { for i in 0..seg_count {
let (a, b) = (pts[i], pts[(i + 1) % n]); let (a, b) = (pts[i], pts[(i + 1) % n]);
let len = a.distance(b); let len = a.distance(b);
out.push(a); out.0.push(a);
for (t, _, _) in &profile.points { out.1.push(corner[i]);
let d = t * total; let mut inner: Vec<f64> = marks.iter().copied().filter(|d| *d > acc + 1e-9 && *d < acc + len - 1e-9).collect();
if d > acc + 1e-9 && d < acc + len - 1e-9 { inner.sort_by(f64::total_cmp);
out.push(a.lerp(b, (d - acc) / len)); for d in inner {
} out.0.push(a.lerp(b, (d - acc) / len));
out.1.push(false);
} }
acc += len; acc += len;
} }
if !closed { if !closed {
out.push(pts[n - 1]); out.0.push(pts[n - 1]);
out.1.push(corner[n - 1]);
} }
out out
} }
fn outline_subpath(out: &mut BezPath, pts: &[Point], closed: bool, width: f64, profile: &WidthProfile, cap: LineCap) { /// What outlines one variable-width stroke.
let pts = &densify(pts, closed, profile)[..]; struct Ctx<'a> {
let n = pts.len(); half: f64,
let seg_count = if closed { n } else { n - 1 }; profile: &'a WidthProfile,
let seg_dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); cap: LineCap,
let mut cum = vec![0.0; n + 1]; join: LineJoin,
for i in 0..seg_count { miter_limit: f64,
cum[i + 1] = cum[i] + pts[i].distance(pts[(i + 1) % n]); tol: f64,
} }
let total = cum[seg_count];
if total <= 1e-12 { /// A vertex of a flattened subpath: where it is, the directions in and out, the lengths of the
return; /// segments either side and whether it is a corner of the path (else a smooth point).
} struct Vertex {
// Vertex normals (mitred, limited to 4× so sharp corners don't spike). p: Point,
let normal_at = |i: usize| -> (Vec2, f64) { a: Vec2,
let (a, b) = if closed { b: Vec2,
(seg_dir[(i + seg_count - 1) % seg_count], seg_dir[i % seg_count]) la: f64,
} else if i == 0 { lb: f64,
(seg_dir[0], seg_dir[0]) corner: bool,
} else if i == n - 1 { }
(seg_dir[seg_count - 1], seg_dir[seg_count - 1])
} else { impl Ctx<'_> {
(seg_dir[i - 1], seg_dir[i]) fn subpath(&self, out: &mut BezPath, pts: &[Point], corner: &[bool], closed: bool, span: (f64, f64)) {
let (pts, corner) = densify(pts, corner, closed, self.profile, span);
let n = pts.len();
let seg_count = if closed { n } else { n - 1 };
let seg_dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect();
let mut cum = vec![0.0; n + 1];
for i in 0..seg_count {
cum[i + 1] = cum[i] + pts[i].distance(pts[(i + 1) % n]);
}
let total = cum[seg_count];
if total <= 1e-12 {
return;
}
// Left and right offsets at `d` along this piece.
let width_at = |d: f64| {
let t = span.0 + (span.1 - span.0) * d / total;
let (l, r) = self.profile.at(if t > 1.0 { t - 1.0 } else { t });
(self.half * l.max(0.0), self.half * r.max(0.0))
}; };
let (na, nb) = (left(a), left(b)); let mut lpts = Vec::with_capacity(n);
let nm = unit(na + nb); let mut rpts = Vec::with_capacity(n);
let c = nm.dot(nb).max(0.25); for (i, &p) in pts.iter().enumerate() {
(nm, 1.0 / c) let (wl, wr) = width_at(cum[i]);
}; if !closed && (i == 0 || i == n - 1) {
let half = width / 2.0; let nm = left(seg_dir[i.min(seg_count - 1)]);
let mut lpts = Vec::with_capacity(n); lpts.push(p + nm * wl);
let mut rpts = Vec::with_capacity(n); rpts.push(p - nm * wr);
for (i, p) in pts.iter().enumerate() { continue;
let (nm, miter) = normal_at(i); }
let (fl, fr) = profile.at(cum[i] / total); let ip = if i == 0 { seg_count - 1 } else { i - 1 };
lpts.push(*p + nm * (miter * half * fl.max(0.0))); let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] };
rpts.push(*p - nm * (miter * half * fr.max(0.0))); self.join(&mut lpts, &v, wl, 1.0);
self.join(&mut rpts, &v, wr, -1.0);
}
if closed {
polygon(out, lpts.into_iter());
polygon(out, rpts.into_iter().rev());
return;
}
let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]);
let ((wl0, wr0), (wl1, wr1)) = (width_at(0.0), width_at(total));
let mut ring = lpts;
// End cap: from the left side around the end to the right side.
cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, self.cap);
ring.extend(rpts.into_iter().rev());
// Start cap: from the right side around the start back to the left side.
cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, self.cap);
polygon(out, ring.into_iter());
} }
if closed {
polygon(out, lpts.iter().copied()); /// The offset points at `v` on one side (`side` 1: left, −1: right), `w` from the path: the
polygon(out, rpts.iter().rev().copied()); /// join on the outer side of a turn, the inner miter point (or a detour through the vertex
return; /// when that would overshoot a segment) on the inner side.
fn join(&self, pts: &mut Vec<Point>, v: &Vertex, w: f64, side: f64) {
let (na, nb) = (left(v.a) * side, left(v.b) * side);
let mid = na + nb;
// Cosine of half the turn; the miter point is `w / c` out along `mid`.
let c = mid.hypot() / 2.0;
let miter = |pts: &mut Vec<Point>| pts.push(v.p + mid * (w / (2.0 * c * c)));
if w <= 0.0 {
pts.push(v.p);
} else if v.b.dot(na) <= 0.0 {
// Outer side: the path turns away from it.
let excess = if c > 1e-9 { w * (1.0 / c - 1.0) } else { f64::INFINITY };
match self.join {
_ if excess <= self.tol => miter(pts),
LineJoin::Miter if v.corner && 1.0 / c <= self.miter_limit => miter(pts),
LineJoin::Miter | LineJoin::Bevel if v.corner => pts.extend([v.p + na * w, v.p + nb * w]),
_ => self.arc(pts, v, na, nb, w),
}
} else if c > 1e-9 && w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) {
miter(pts);
} else {
pts.extend([v.p + na * w, v.p, v.p + nb * w]);
}
}
/// A round join from `na` to `nb` (unit normals) at radius `w` around `v`; a full reversal
/// rounds through the direction of travel.
fn arc(&self, pts: &mut Vec<Point>, v: &Vertex, na: Vec2, nb: Vec2, w: f64) {
let phi =
if (na + nb).hypot() < 1e-9 { if Vec2::new(-na.y, na.x).dot(v.a) > 0.0 { PI } else { -PI } } else { na.cross(nb).atan2(na.dot(nb)) };
let step = 2.0 * (1.0 - (self.tol / w).min(1.0)).acos();
let steps = ((phi.abs() / step.max(1e-3)).ceil() as usize).clamp(1, 256);
for k in 0..=steps {
let (s, c) = (phi * k as f64 / steps as f64).sin_cos();
pts.push(v.p + Vec2::new(na.x * c - na.y * s, na.x * s + na.y * c) * w);
}
} }
let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]);
let (fl0, fr0) = profile.at(0.0);
let (fl1, fr1) = profile.at(1.0);
let (wl0, wr0, wl1, wr1) = (half * fl0.max(0.0), half * fr0.max(0.0), half * fl1.max(0.0), half * fr1.max(0.0));
let mut ring: Vec<Point> = lpts.clone();
// End cap: from the left side around the end to the right side.
cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, cap);
ring.extend(rpts.iter().rev());
// Start cap: from the right side around the start back to the left side.
cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, cap);
polygon(out, ring.into_iter());
} }
/// Cap points between `c + u·r0` and `c − u·r1`, bulging towards `v`. /// Cap points between `c + u·r0` and `c − u·r1`, bulging towards `v`.
@ -193,22 +276,28 @@ mod tests {
b.area().abs() b.area().abs()
} }
fn capped(cap: LineCap) -> StrokeLayer {
let mut st = StrokeLayer::new(vectorcraft_color::Paint::None, 10.0);
st.cap = cap;
st
}
#[test] #[test]
fn uniform_profile_matches_a_plain_stroke() { fn uniform_profile_matches_a_plain_stroke() {
let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] };
assert!((area(&width_outline(&line(), 10.0, &p, LineCap::Butt, 0.01)) - 1000.0).abs() < 1e-6); assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Butt), 0.01)) - 1000.0).abs() < 1e-6);
let round = area(&width_outline(&line(), 10.0, &p, LineCap::Round, 0.01)); let round = area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Round), 0.01));
let want = 1000.0 + std::f64::consts::PI * 25.0; let want = 1000.0 + std::f64::consts::PI * 25.0;
assert!((round - want).abs() / want < 0.01, "{round} vs {want}"); assert!((round - want).abs() / want < 0.01, "{round} vs {want}");
assert!((area(&width_outline(&line(), 10.0, &p, LineCap::Square, 0.01)) - 1100.0).abs() < 1e-6); assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Square), 0.01)) - 1100.0).abs() < 1e-6);
} }
#[test] #[test]
fn lens_profile_is_a_diamond_and_one_sided_profiles_stay_on_their_side() { fn lens_profile_is_a_diamond_and_one_sided_profiles_stay_on_their_side() {
let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), LineCap::Butt, 0.01)); let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01));
assert!((a - 500.0).abs() < 1e-6, "{a}"); assert!((a - 500.0).abs() < 1e-6, "{a}");
let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] }; let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] };
let o = width_outline(&line(), 10.0, &one, LineCap::Butt, 0.01); let o = width_outline(&line(), 10.0, &one, &capped(LineCap::Butt), 0.01);
assert!((area(&o) - 500.0).abs() < 1e-6); assert!((area(&o) - 500.0).abs() < 1e-6);
let bb = o.bounding_box(); let bb = o.bounding_box();
// Travelling +x, the left side is up (negative y). // Travelling +x, the left side is up (negative y).
@ -224,7 +313,7 @@ mod tests {
sq.line_to((0.0, 100.0)); sq.line_to((0.0, 100.0));
sq.close_path(); sq.close_path();
let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] };
let a = area(&width_outline(&sq, 10.0, &p, LineCap::Butt, 0.01)); let a = area(&width_outline(&sq, 10.0, &p, &capped(LineCap::Butt), 0.01));
assert!((a - (110.0 * 110.0 - 90.0 * 90.0)).abs() < 1e-6, "{a}"); assert!((a - (110.0 * 110.0 - 90.0 * 90.0)).abs() < 1e-6, "{a}");
} }
@ -233,7 +322,7 @@ mod tests {
// A circle of radius 50 with a uniform 10 pt stroke: ring area 2π·50·10. // A circle of radius 50 with a uniform 10 pt stroke: ring area 2π·50·10.
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-4); let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-4);
let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] };
let a = area(&width_outline(&c, 10.0, &p, LineCap::Butt, 0.001)); let a = area(&width_outline(&c, 10.0, &p, &capped(LineCap::Butt), 0.001));
let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0; let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0;
assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); assert!((a - want).abs() / want < 0.01, "{a} vs {want}");
} }

View File

@ -212,11 +212,17 @@ fn offset_path_grows_and_shrinks() {
} }
#[test] #[test]
fn outline_stroke_uses_context_width() { fn outline_stroke_uses_context_stroke() {
let ctx = GeomContext { stroke_width: 10.0 }; let st = vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 10.0);
let ctx = GeomContext { stroke: Some(&st), ..Default::default() };
let out = apply_geometry_with(&[fx("path.outlineStroke", json!({}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx); let out = apply_geometry_with(&[fx("path.outlineStroke", json!({}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx);
let b = out.bounds().unwrap(); let b = out.bounds().unwrap();
assert!(close(b.width(), 110.0, 0.1), "{b:?}"); assert!(close(b.width(), 110.0, 0.1), "{b:?}");
// An explicit width overrides the weight; without a stroke a plain 1 pt one is outlined.
let out = apply_geometry_with(&[fx("path.outlineStroke", json!({"width": 4}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx);
assert!(close(out.bounds().unwrap().width(), 104.0, 0.1));
let out = run("path.outlineStroke", json!({}), &square());
assert!(close(out.bounds().unwrap().width(), 101.0, 0.1));
} }
#[test] #[test]

View File

@ -66,7 +66,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc, has_doc,
set_item set_item
), ),
cmd!("appearance.removeItem", "Remove Item", [], None, "{index, ids?}", has_selection, remove_item), cmd!(
"appearance.removeItem",
"Remove Item",
[],
None,
"{index | indices: [..] (paint-order item indices), ids?} remove those fills/strokes from each target object's own stack",
has_selection,
remove_item
),
cmd!( cmd!(
"appearance.addEffect", "appearance.addEffect",
"Add Effect", "Add Effect",
@ -76,14 +84,22 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection, has_selection,
super::effectcmd::apply super::effectcmd::apply
), ),
cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index, ids?}", has_selection, duplicate_item), cmd!(
"appearance.duplicateItem",
"Duplicate Item",
["Window", "Appearance"],
None,
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it), ids?} copy fills/strokes with their effects",
has_selection,
duplicate_item
),
cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to, ids?} (paint-order indices)", has_selection, move_item), cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to, ids?} (paint-order indices)", has_selection, move_item),
cmd!( cmd!(
"appearance.copyFrom", "appearance.copyFrom",
"Eyedropper", "Eyedropper",
[], [],
None, None,
"{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults", "{source: id, ids?, appearance?: bool, transparency?: bool (default: eyedropper.setOptions)} copy fill, stroke, weight and effects (appearance) and opacity and blend (transparency) from `source` to ids (default: selection), the appearance also to the paint defaults; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art)",
has_doc, has_doc,
copy_from copy_from
), ),
@ -96,6 +112,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection, has_selection,
set_active_item set_active_item
), ),
cmd!(
"appearance.showAllHidden",
"Show All Hidden Attributes",
["Window", "Appearance"],
None,
"{ids?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}",
has_selection,
show_all_hidden
),
] ]
} }
@ -146,12 +171,17 @@ pub(crate) enum ItemTarget {
/// Parse the `item` param of `cmd` (see the module docs). /// Parse the `item` param of `cmd` (see the module docs).
pub(crate) fn item_target(s: &Session, p: &Value, cmd: &str) -> Result<ItemTarget> { pub(crate) fn item_target(s: &Session, p: &Value, cmd: &str) -> Result<ItemTarget> {
match p.get("item") { item_target_at(s, p, "item", cmd)
}
/// [`item_target`] read from param `key` (e.g. `fromItem`).
pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<ItemTarget> {
match p.get(key) {
Some(Value::Null) => Ok(ItemTarget::Top), Some(Value::Null) => Ok(ItemTarget::Top),
Some(v) => v Some(v) => v
.as_u64() .as_u64()
.map(|i| ItemTarget::Item { index: i as usize, explicit: true }) .map(|i| ItemTarget::Item { index: i as usize, explicit: true })
.ok_or_else(|| bad(cmd, "`item` must be an appearance item index (paint order) or null")), .ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))),
None if p.get("ids").is_some() || p.get("id").is_some() => Ok(ItemTarget::Top), None if p.get("ids").is_some() || p.get("id").is_some() => Ok(ItemTarget::Top),
None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false })), None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false })),
} }
@ -385,45 +415,92 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
ok() ok()
} }
/// The paint-order item indices a command acts on (`indices`, else `index`), ascending and
/// without repeats.
fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
let mut v = match p.get("indices") {
Some(a) => a
.as_array()
.and_then(|a| a.iter().map(|x| x.as_u64().map(|i| i as usize)).collect::<Option<Vec<_>>>())
.filter(|v| !v.is_empty())
.ok_or_else(|| bad(cmd, "`indices` must be a non-empty array of item indices"))?,
None => vec![index_param(p, "index", cmd)?],
};
v.sort_unstable();
v.dedup();
Ok(v)
}
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> { fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = index_param(p, "index", "appearance.removeItem")?; let idx = item_indices(p, "appearance.removeItem")?;
let ids = appearance_targets(s, p)?; let ids = appearance_targets(s, p)?;
s.edit("Remove Item", |d, _| { s.edit("Remove Item", |d, _| {
for id in &ids { for id in &ids {
if let Some(n) = d.node_mut(*id) if let Some(n) = d.node_mut(*id) {
&& idx < n.appearance.items.len() let items = &mut n.appearance.items;
{ // From the top down, so the lower indices stay valid.
n.appearance.items.remove(idx); for &i in idx.iter().rev() {
if i < items.len() {
items.remove(i);
}
}
} }
} }
Ok(()) Ok(())
})?; })?;
s.remap_appearance_item(&ids, |a| match a.cmp(&idx) { s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
std::cmp::Ordering::Less => Some(a),
std::cmp::Ordering::Equal => None,
std::cmp::Ordering::Greater => Some(a - 1),
});
ok() ok()
} }
fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> { fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = index_param(p, "index", "appearance.duplicateItem")?; const C: &str = "appearance.duplicateItem";
let idx = item_indices(p, C)?;
let to = p.get("to").map(|_| index_param(p, "to", C)).transpose()?;
if to.is_some() && idx.len() > 1 {
return Err(bad(C, "`to` takes a single `index`"));
}
let ids = appearance_targets(s, p)?; let ids = appearance_targets(s, p)?;
// Where the first object's copy landed (the active item lives in the first object's stack).
let mut first_at = None;
s.edit("Duplicate Item", |d, _| { s.edit("Duplicate Item", |d, _| {
let mut any = false; let mut any = false;
for id in &ids { for id in &ids {
let Some(n) = d.node_mut(*id) else { continue }; let Some(n) = d.node_mut(*id) else { continue };
if let Some(item) = n.appearance.items.get(idx).cloned() { let items = &mut n.appearance.items;
n.appearance.items.insert(idx + 1, item); // From the top down, so the lower indices stay valid.
any = true; for &i in idx.iter().rev() {
if let Some(item) = items.get(i).cloned() {
let at = to.map_or(i + 1, |t| t.min(items.len()));
first_at.get_or_insert(at);
items.insert(at, item);
any = true;
}
} }
} }
if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) } if any { Ok(()) } else { Err(bad(C, format!("no item at index {}", idx[0]))) }
})?; })?;
s.remap_appearance_item(&ids, |a| Some(if a > idx { a + 1 } else { a })); // Rows at or above each copy move up by one.
s.remap_appearance_item(&ids, |a| {
Some(match to {
Some(_) => a + usize::from(first_at.is_some_and(|at| at <= a)),
None => a + idx.iter().filter(|i| **i < a).count(),
})
});
ok() ok()
} }
fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
let roots = appearance_targets(s, p)?;
let ids = s.edit("Show All Hidden Attributes", |d, _| {
let shown: Vec<NodeId> = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect();
if shown.is_empty() {
return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into()));
}
Ok(shown)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn move_item(s: &mut Session, p: &Value) -> Result<Value> { fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.moveItem"; const C: &str = "appearance.moveItem";
let from = index_param(p, "from", C)?; let from = index_param(p, "from", C)?;
@ -461,18 +538,31 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> { fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?; let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?;
let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?; let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?;
let appearance = match &src.kind { // The appearance and the box its placed gradients are relative to (type: its first run's
NodeKind::Text(t) => match t.runs.first() { // paints, in text space).
Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), let (appearance, src_box) = match &src.kind {
None => src.appearance.clone(), NodeKind::Text(t) if !t.runs.is_empty() => {
}, let r = &t.runs[0];
_ => src.appearance.clone(), (Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), Some(t.local_bounds()))
}
_ => (src.appearance.clone(), src.geometric_bounds()),
}; };
s.paint.fill = appearance.fill_paint(); // `appearance` placed on an object whose box (in its paint space) is `to`.
s.paint.stroke = appearance.stroke_paint(); let placed = |to: Option<vectorcraft_geom::Rect>| {
s.remember_paint(&s.paint.fill.clone()); let mut ap = appearance.clone();
if appearance.stroke().is_some() { if let (Some(f), Some(t)) = (src_box, to) {
s.paint.stroke_width = appearance.stroke_width(); ap.rebase_gradients(f, t);
}
ap
};
let opts = s.eyedropper.with(p);
if opts.appearance {
s.paint.fill = super::gradient::unplaced(&appearance.fill_paint());
s.paint.stroke = super::gradient::unplaced(&appearance.stroke_paint());
s.remember_paint(&s.paint.fill.clone());
if appearance.stroke().is_some() {
s.paint.stroke_width = appearance.stroke_width();
}
} }
let ids = match ids_param(p, "ids") { let ids = match ids_param(p, "ids") {
Some(v) => v, Some(v) => v,
@ -480,24 +570,30 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
}; };
let mut targets = leaf_targets(s, &ids)?; let mut targets = leaf_targets(s, &ids)?;
targets.retain(|id| *id != src_id); targets.retain(|id| *id != src_id);
if targets.is_empty() { if targets.is_empty() || !(opts.appearance || opts.transparency) {
return Ok(json!({ "ids": [] })); return Ok(json!({ "ids": [] }));
} }
let (opacity, blend) = (src.opacity, src.blend); let (opacity, blend) = (src.opacity, src.blend);
s.edit("Eyedropper", |d, _| { s.edit("Eyedropper", |d, _| {
for id in &targets { for id in &targets {
let Some(n) = d.node_mut(*id) else { continue }; let Some(n) = d.node_mut(*id) else { continue };
n.opacity = opacity; if opts.transparency {
n.blend = blend; n.opacity = opacity;
n.blend = blend;
}
if !opts.appearance {
continue;
}
if let NodeKind::Text(t) = &mut n.kind { if let NodeKind::Text(t) = &mut n.kind {
let ap = placed(Some(t.local_bounds()));
for r in &mut t.runs { for r in &mut t.runs {
r.style.fill = appearance.fill_paint(); r.style.fill = ap.fill_paint();
r.style.stroke = appearance.stroke_paint(); r.style.stroke = ap.stroke_paint();
r.style.stroke_width = appearance.stroke_width(); r.style.stroke_width = ap.stroke_width();
} }
continue; continue;
} }
n.appearance = appearance.clone(); n.appearance = placed(n.geometric_bounds());
} }
Ok(()) Ok(())
})?; })?;

View File

@ -6,7 +6,8 @@
use std::sync::Arc; use std::sync::Arc;
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint}; use vectorcraft_color::cms::Model;
use vectorcraft_color::{Color, Paint, keep_model};
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind}; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind};
use super::edit::selected_roots; use super::edit::selected_roots;
@ -100,29 +101,12 @@ pub(crate) fn invert(c: Color) -> Color {
c.invert_keep_model() c.invert_keep_model()
} }
/// Express `new` in the colour model of `orig`.
fn keep_model(orig: Color, new: Color) -> Color {
match orig {
Color::Rgb { .. } => {
let [r, g, b] = new.to_rgb();
Color::rgb(r, g, b)
}
Color::Cmyk { .. } => to_cmyk(new),
Color::Gray { .. } => to_gray(new),
}
}
pub(crate) fn to_cmyk(c: Color) -> Color { pub(crate) fn to_cmyk(c: Color) -> Color {
let [c, m, y, k] = c.to_cmyk(); c.in_model(Model::Cmyk)
Color::cmyk(c, m, y, k)
} }
pub(crate) fn to_gray(c: Color) -> Color { pub(crate) fn to_gray(c: Color) -> Color {
if let Color::Gray { .. } = c { c.in_model(Model::Gray)
return c;
}
let [r, g, b] = c.to_rgb();
Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0))
} }
/// Apply `f` to a paint; returns whether anything changed. /// Apply `f` to a paint; returns whether anything changed.
@ -318,6 +302,8 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
} }
let first = d.node(ids[0]).and_then(fill_color).unwrap_or_default(); let first = d.node(ids[0]).and_then(fill_color).unwrap_or_default();
let last = d.node(*ids.last().unwrap()).and_then(fill_color).unwrap_or_default(); let last = d.node(*ids.last().unwrap()).and_then(fill_color).unwrap_or_default();
// Ends in one model blend in it; mixed ones give colours in the document's model.
let model = if first.model() == last.model() { first.model() } else { d.color_mode.model() };
let n = ids.len(); let n = ids.len();
let label = match order { let label = match order {
BlendOrder::Stack => "Blend Front to Back", BlendOrder::Stack => "Blend Front to Back",
@ -327,7 +313,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
let changed = s.edit(label, |d, _| { let changed = s.edit(label, |d, _| {
let mut changed = 0; let mut changed = 0;
for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) { for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) {
let c = lerp_model(first, last, i as f32 / (n - 1) as f32); let c = lerp_model(first, last, i as f32 / (n - 1) as f32).in_model(model);
if let Some(node) = d.node_mut(*id) { if let Some(node) = d.node_mut(*id) {
changed += map_node_colors(node, &|_| c, true, false); changed += map_node_colors(node, &|_| c, true, false);
} }

View File

@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Info", "Document Info",
["Window", "Document Info"], ["Window", "Document Info"],
None, None,
"{selectionOnly?} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, …}", "{selectionOnly?} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …}",
has_doc, has_doc,
info info
), ),
@ -43,16 +43,15 @@ fn paint_kind(p: &Paint) -> Option<&'static str> {
fn info(s: &mut Session, p: &Value) -> Result<Value> { fn info(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?; let st = s.doc()?;
let d = &st.doc; let d = &st.doc;
let roots: Vec<&Node> = if bool_or(p, "selectionOnly", false) { let selection_only = bool_or(p, "selectionOnly", false);
st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() let roots: Vec<&Node> =
} else { if selection_only { st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() } else { d.layers.iter().map(|l| &**l).collect() };
d.layers.iter().map(|l| &**l).collect()
};
let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
let mut fonts = BTreeSet::new(); let mut fonts = BTreeSet::new();
let mut images: BTreeMap<String, Value> = BTreeMap::new(); let mut images: BTreeMap<String, Value> = BTreeMap::new();
let mut symbols = BTreeSet::new(); let mut symbols = BTreeSet::new();
let mut spot = BTreeSet::new(); let mut spot = BTreeSet::new();
let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() };
for r in roots { for r in roots {
r.walk(&mut |n| { r.walk(&mut |n| {
let k = match &n.kind { let k = match &n.kind {
@ -104,6 +103,12 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
if !n.appearance.effects.is_empty() { if !n.appearance.effects.is_empty() {
*counts.entry("liveEffects").or_default() += 1; *counts.entry("liveEffects").or_default() += 1;
} }
if selection_only
&& let Some(g) = super::style::linked_style(d, n)
&& !styles.contains(&g.name.as_str())
{
styles.push(&g.name);
}
}); });
} }
Ok(json!({ Ok(json!({
@ -115,6 +120,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
"spotColors": spot, "spotColors": spot,
"swatches": d.swatches_iter().count(), "swatches": d.swatches_iter().count(),
"graphicStyles": d.graphic_styles.len(), "graphicStyles": d.graphic_styles.len(),
"graphicStyleNames": styles,
"characterStyles": d.char_styles.len(), "characterStyles": d.char_styles.len(),
"paragraphStyles": d.para_styles.len(), "paragraphStyles": d.para_styles.len(),
"patterns": d.patterns.len(), "patterns": d.patterns.len(),

View File

@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New…", "New…",
["File"], ["File"],
Some("Cmd+N"), Some("Cmd+N"),
"{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\"}", "{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK)}",
always, always,
file_new file_new
), ),
@ -58,14 +58,15 @@ fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
if !(w > 0.0 && h > 0.0) || w > 16383.0 * 10.0 || h > 16383.0 * 10.0 { if !(w > 0.0 && h > 0.0) || w > 16383.0 * 10.0 || h > 16383.0 * 10.0 {
return Err(bad("file.new", "width/height must be positive and within the canvas")); return Err(bad("file.new", "width/height must be positive and within the canvas"));
} }
let mut d = Document::new(w, h); let mode = match str_param(p, "colorMode") {
Some(m) if m.eq_ignore_ascii_case("cmyk") => vectorcraft_doc::ColorMode::Cmyk,
_ => vectorcraft_doc::ColorMode::Rgb,
};
let mut d = Document::new_with_mode(w, h, mode);
d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled()); d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled());
if let Some(u) = str_param(p, "units") { if let Some(u) = str_param(p, "units") {
d.units = parse_unit(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?; d.units = parse_unit(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?;
} }
if str_param(p, "colorMode").is_some_and(|m| m.eq_ignore_ascii_case("cmyk")) {
d.color_mode = vectorcraft_doc::ColorMode::Cmyk;
}
let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize; let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize;
for i in 1..n { for i in 1..n {
let r = vectorcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0); let r = vectorcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0);

View File

@ -10,7 +10,7 @@ use vectorcraft_doc::{Effect, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_render::effects; use vectorcraft_render::effects;
use super::appearance::{ItemTarget, appearance_targets, index_param, item_target}; use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at};
use super::*; use super::*;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
@ -69,6 +69,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection, has_selection,
duplicate duplicate
), ),
cmd!(
"effect.move",
"Move Effect",
[],
None,
"{from: int (position in the source list), to: int (its position in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?: [..]} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
has_selection,
move_effect
),
] ]
} }
@ -175,6 +184,38 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids) })) Ok(json!({ "ids": ids_json(&ids) }))
} }
fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.move";
let from = index_param(p, "from", C)?;
let to = index_param(p, "to", C)?;
let src = item_target_at(s, p, "fromItem", C)?;
let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src };
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
let roots = appearance_targets(s, p)?;
// Where the effect landed in the first object.
let mut landed = None;
let ids = s.edit(if copy { "Duplicate Effect" } else { "Move Effect" }, |d, _| {
let mut done = vec![];
for id in &roots {
let Some(n) = d.node_mut(*id) else { continue };
let (si, di) = (src.effects_item(&n.appearance, C)?, dst.effects_item(&n.appearance, C)?);
let Some(fx) = n.appearance.effects_mut(si).filter(|fx| from < fx.len()) else { continue };
let e = if copy { fx[from].clone() } else { fx.remove(from) };
let Some(fx) = n.appearance.effects_mut(di) else { continue };
let at = to.min(fx.len());
fx.insert(at, e);
landed.get_or_insert((at, di));
done.push(*id);
}
if done.is_empty() {
return Err(EngineError::Other(format!("{C}: no effect at index {from}")));
}
Ok(done)
})?;
let (index, item) = landed.unzip();
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": item.flatten() }))
}
fn set_params(s: &mut Session, p: &Value) -> Result<Value> { fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams"; const C: &str = "effect.setParams";
let index = index_param(p, "index", C)?; let index = index_param(p, "index", C)?;
@ -249,9 +290,7 @@ fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec<Node
} }
let Some((path, rule)) = node_geometry(&n) else { return }; let Some((path, rule)) = node_geometry(&n) else { return };
let Some(bounds) = path.bounds() else { return }; let Some(bounds) = path.bounds() else { return };
let w = n.appearance.stroke_width(); let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &effects::GeomContext::of(&n));
let ctx = effects::GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } };
let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &ctx);
let kind = if matches!(n.kind, NodeKind::Compound { .. }) || baked.subpaths.len() > 1 { let kind = if matches!(n.kind, NodeKind::Compound { .. }) || baked.subpaths.len() > 1 {
let children = baked let children = baked
.subpaths .subpaths

View File

@ -55,11 +55,7 @@ pub(crate) fn mask_group(children: Vec<Arc<Node>>) -> Node {
fn is_mask_group(n: &Node) -> bool { fn is_mask_group(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { clip: false, .. }) matches!(n.kind, NodeKind::Group { clip: false, .. })
&& n.name.as_deref() == Some(MASK_GROUP) && n.name.as_deref() == Some(MASK_GROUP)
&& n.opacity >= 1.0 && n.has_default_transparency()
&& n.blend == vectorcraft_doc::color::BlendMode::Normal
&& !n.isolate
&& !n.knockout
&& n.mask.is_none()
&& n.appearance.items.is_empty() && n.appearance.items.is_empty()
&& n.appearance.effects.is_empty() && n.appearance.effects.is_empty()
} }

View File

@ -346,3 +346,6 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result<Vec<usiz
/// The Transparency panel's state ([`Session::transparency_info`]). /// The Transparency panel's state ([`Session::transparency_info`]).
pub use opacitymask::TransparencyInfo; pub use opacitymask::TransparencyInfo;
/// What the Eyedropper copies (`Session::eyedropper`).
pub use xform::EyedropperOptions;

View File

@ -5,7 +5,7 @@ use std::sync::Arc;
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind}; use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2}; use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
use super::edit::{duplicate_in, selected_roots}; use super::edit::{duplicate_in, selected_roots};
@ -131,7 +131,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Properties", "Object Properties",
[], [],
None, None,
"{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?}", "{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool}",
has_doc, has_doc,
set_props set_props
), ),
@ -644,6 +644,13 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?), Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?),
None => None, None => None,
}; };
let knockout = match p.get("knockout") {
Some(v) => Some(
Knockout::from_value(v)
.ok_or_else(|| bad("object.setProps", format!("knockout must be \"on\", \"off\", \"neutral\" or a bool, not {v}")))?,
),
None => None,
};
s.edit("Object Properties", |d, _| { s.edit("Object Properties", |d, _| {
for id in &ids { for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
@ -665,8 +672,11 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = p.get("isolate").and_then(Value::as_bool) { if let Some(v) = p.get("isolate").and_then(Value::as_bool) {
n.isolate = v; n.isolate = v;
} }
if let Some(v) = p.get("knockout").and_then(Value::as_bool) { if let Some(k) = knockout {
n.knockout = v; n.knockout = k;
}
if let Some(v) = p.get("knockoutShape").and_then(Value::as_bool) {
n.knockout_shape = v;
} }
} }
Ok(()) Ok(())

View File

@ -10,7 +10,7 @@ use std::collections::HashSet;
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::BlendMode; use vectorcraft_color::BlendMode;
use vectorcraft_doc::{Document, Node, NodeId, OpacityMask, Selection}; use vectorcraft_doc::{Document, Knockout, Node, NodeId, OpacityMask, Selection};
use super::edit::selected_roots; use super::edit::selected_roots;
use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing}; use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing};
@ -108,10 +108,28 @@ pub fn specs() -> Vec<CommandSpec> {
"Transparency Info", "Transparency Info",
[], [],
None, None,
"{id?|ids?} → {ids, opacity: 0..100, blend, isolate, knockout, editingMask} the Transparency panel's values for `ids`, the selection, or the object whose mask is being edited. A value is null where those objects differ (or there are none); editingMask is the id of the object whose mask is being edited, or null", "{id?|ids?} → {ids, opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", knockoutShape, editingMask, pageIsolatedBlending, pageKnockoutGroup} the Transparency panel's values for `ids`, the selection, or the object whose mask is being edited. A value is null where those objects differ (or there are none); editingMask is the id of the object whose mask is being edited, or null; the page values are the document's",
has_doc, has_doc,
info info
), ),
cmd!(
"transparency.togglePageIsolatedBlending",
"Page Isolated Blending",
["Window", "Transparency"],
None,
"{value?} make the page an isolated group (default: toggle): blend modes of top-level objects don't blend with what lies under the page; saved with the document and written to PDF → {value}",
has_doc,
|s, p| toggle_page(s, p, false)
),
cmd!(
"transparency.togglePageKnockoutGroup",
"Page Knockout Group",
["Window", "Transparency"],
None,
"{value?} make the page a knockout group (default: toggle): its layers (the contents of neutral layers) hide what they cover instead of showing it through their transparency; saved with the document and written to PDF → {value}",
has_doc,
|s, p| toggle_page(s, p, true)
),
] ]
} }
@ -248,6 +266,24 @@ fn toggle_new_clip(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "value": v })) Ok(json!({ "value": v }))
} }
/// Set (or toggle) the page's knockout group flag (`knockout`) or isolated blending flag, as one
/// undo step; setting the current value changes nothing.
fn toggle_page(s: &mut Session, p: &Value, knockout: bool) -> Result<Value> {
fn flag(d: &mut Document, knockout: bool) -> &mut bool {
if knockout { &mut d.page_knockout } else { &mut d.page_isolate }
}
let d = &s.doc()?.doc;
let cur = if knockout { d.page_knockout } else { d.page_isolate };
let v = bool_or(p, "value", !cur);
if v != cur {
s.edit(if knockout { "Page Knockout Group" } else { "Page Isolated Blending" }, |d, _| {
*flag(d, knockout) = v;
Ok(())
})?;
}
Ok(json!({ "value": v }))
}
fn toggle_new_invert(s: &mut Session, p: &Value) -> Result<Value> { fn toggle_new_invert(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_or(p, "value", !s.menu.new_masks_inverted); let v = bool_or(p, "value", !s.menu.new_masks_inverted);
s.menu.new_masks_inverted = v; s.menu.new_masks_inverted = v;
@ -264,7 +300,8 @@ pub struct TransparencyInfo {
pub opacity: Option<f64>, pub opacity: Option<f64>,
pub blend: Option<BlendMode>, pub blend: Option<BlendMode>,
pub isolate: Option<bool>, pub isolate: Option<bool>,
pub knockout: Option<bool>, pub knockout: Option<Knockout>,
pub knockout_shape: Option<bool>,
} }
impl TransparencyInfo { impl TransparencyInfo {
@ -280,6 +317,7 @@ impl TransparencyInfo {
blend: same(&nodes, |n| n.blend), blend: same(&nodes, |n| n.blend),
isolate: same(&nodes, |n| n.isolate), isolate: same(&nodes, |n| n.isolate),
knockout: same(&nodes, |n| n.knockout), knockout: same(&nodes, |n| n.knockout),
knockout_shape: same(&nodes, |n| n.knockout_shape),
ids, ids,
} }
} }
@ -324,8 +362,11 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
"opacity": i.opacity, "opacity": i.opacity,
"blend": i.blend.map(BlendMode::label), "blend": i.blend.map(BlendMode::label),
"isolate": i.isolate, "isolate": i.isolate,
"knockout": i.knockout, "knockout": i.knockout.map(Knockout::label),
"knockoutShape": i.knockout_shape,
"editingMask": st.doc.mask_edit.map(|me| me.object.0), "editingMask": st.doc.mask_edit.map(|me| me.object.0),
"pageIsolatedBlending": st.doc.page_isolate,
"pageKnockoutGroup": st.doc.page_knockout,
})) }))
} }

View File

@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill", "Fill",
[], [],
None, None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy)} sets the selection's fill and the default (new art fits a gradient to itself)", "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
has_doc, has_doc,
|s, p| set_paint(s, p, true) |s, p| set_paint(s, p, true)
), ),
@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transparency", "Transparency",
["Window", "Transparency"], ["Window", "Transparency"],
None, None,
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?, item?: appearance item index|null} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)", "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)",
has_doc, has_doc,
transparency transparency
), ),
@ -261,8 +261,21 @@ fn stroke_param(s: &Session, p: &Value) -> bool {
p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active) p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active)
} }
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. /// The model RGB colours given in `p` are stored in: the document's in a CMYK document (`None` in
/// an RGB one, or with `keepModel`).
fn new_color_model(s: &Session, p: &Value) -> Option<vectorcraft_color::cms::Model> {
let mode = s.active()?.doc.color_mode;
(mode != vectorcraft_doc::ColorMode::Rgb && !bool_or(p, "keepModel", false)).then(|| mode.model())
}
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]).
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> { pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
let model = new_color_model(s, p);
let new_color = |c: Color| match (c, model) {
(Color::Rgb { .. }, Some(m)) => c.in_model(m),
_ => c,
};
if bool_or(p, "none", false) { if bool_or(p, "none", false) {
return Ok(Some(Paint::None)); return Ok(Some(Paint::None));
} }
@ -282,12 +295,13 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
return Ok(Some(paint)); return Ok(Some(paint));
} }
if let Some(g) = p.get("gradient") { if let Some(g) = p.get("gradient") {
let gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?; let mut gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?;
gp.gradient.stops.iter_mut().for_each(|st| st.color = new_color(st.color));
return Ok(Some(Paint::Gradient(Box::new(gp)))); return Ok(Some(Paint::Gradient(Box::new(gp))));
} }
if let Some(c) = p.get("color") { if let Some(c) = p.get("color") {
let c = color_value(c).ok_or_else(|| bad("paint", format!("bad color {c}")))?; let c = color_value(c).ok_or_else(|| bad("paint", format!("bad color {c}")))?;
return Ok(Some(Paint::solid(c))); return Ok(Some(Paint::solid(new_color(c))));
} }
Ok(None) Ok(None)
} }
@ -447,7 +461,7 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
let ItemTarget::Item { index, explicit } = item else { let ItemTarget::Item { index, explicit } = item else {
return s.execute("object.setProps", &Value::Object(q)); return s.execute("object.setProps", &Value::Object(q));
}; };
// Opacity and blend belong to the targeted fill/stroke; isolate/knockout stay object-level. // Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level.
let item_ids = if editing_mask { ids } else { appearance_targets(s, p)? }; let item_ids = if editing_mask { ids } else { appearance_targets(s, p)? };
if explicit { if explicit {
let d = &s.doc()?.doc; let d = &s.doc()?.doc;
@ -461,6 +475,11 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
{ {
return Err(bad(C, format!("unknown blend mode {b}"))); return Err(bad(C, format!("unknown blend mode {b}")));
} }
if let Some(k) = q.get("knockout")
&& vectorcraft_doc::Knockout::from_value(k).is_none()
{
return Err(bad(C, format!("unknown knockout state {k}")));
}
if opacity.is_some() || blend.is_some() { if opacity.is_some() || blend.is_some() {
let ids: Vec<u64> = item_ids.iter().map(|id| id.0).collect(); let ids: Vec<u64> = item_ids.iter().map(|id| id.0).collect();
s.execute("appearance.setItem", &json!({ "index": index, "ids": ids, "opacity": opacity, "blend": blend }))?; s.execute("appearance.setItem", &json!({ "index": index, "ids": ids, "opacity": opacity, "blend": blend }))?;

View File

@ -6,7 +6,7 @@ use std::sync::Arc;
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, LineCap, LineJoin, StrokeAlign, StrokeLayer}; use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_pathops as po; use vectorcraft_pathops as po;
@ -450,41 +450,107 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- Outline Stroke ---------- // ---------- Outline Stroke ----------
fn cap_of(c: LineCap) -> po::Cap { /// Can Outline Stroke turn `st` into filled art?
match c { fn outlinable(st: &StrokeLayer) -> bool {
LineCap::Butt => po::Cap::Butt, st.visible && !st.paint.is_none() && st.width > 0.0
LineCap::Round => po::Cap::Round,
LineCap::Square => po::Cap::Square,
}
}
fn join_of(j: LineJoin) -> po::Join {
match j {
LineJoin::Miter => po::Join::Miter,
LineJoin::Round => po::Join::Round,
LineJoin::Bevel => po::Join::Bevel,
}
} }
/// The filled outline of `st` stroking `path` (honours inside/outside alignment on closed paths). /// `n` with `opacity` and `blend` applied: set on it when it has none of its own, else on a
pub(crate) fn stroke_outline(path: &PathData, rule: FillRule, st: &StrokeLayer) -> PathData { /// group around it.
let (cap, join) = (cap_of(st.cap), join_of(st.join)); fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, blend: BlendMode) -> Node {
let w = st.width; if opacity >= 1.0 && blend == BlendMode::Normal {
if !w.is_finite() || w <= 0.0 { return n;
return PathData::default();
} }
match st.align { if n.opacity < 1.0 || n.blend != BlendMode::Normal {
StrokeAlign::Inside | StrokeAlign::Outside if path.is_closed() => { n = Node::group(d.alloc_id(), vec![Arc::new(n)]);
let ring = po::outline_stroke(path, 2.0 * w, cap, join, st.miter_limit); }
let op = if st.align == StrokeAlign::Inside { BoolOp::Intersect } else { BoolOp::Difference }; n.opacity = opacity;
po::boolean(&ring, FillRule::NonZero, path, rule, op) n.blend = blend;
n
}
/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art, or the
/// outline of the stroke (as on the canvas) filled with the stroke's paint, opacity, blend mode
/// and effects. `None` when it paints nothing.
fn outlined_stroke(d: &mut Document, brushes: &[vectorcraft_brush::Brush], path: &PathData, rule: FillRule, st: &StrokeLayer) -> Option<Node> {
if let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
let mut pieces = vectorcraft_brush::stroke_pieces(b, &path.to_bezpath(), st);
let art = match pieces.len() {
0 => return None,
1 => pieces.remove(0),
_ => Node::group(NodeId(0), pieces.into_iter().map(Arc::new).collect()),
};
let art = d.reid(&art);
return Some(with_transparency(d, art, st.opacity, st.blend));
}
let outline = vectorcraft_render::effects::stroke::outline_region(path, rule, st);
if outline.is_empty() {
return None;
}
let mut n = shape_node(d, outline, None);
let fill = vectorcraft_doc::appearance::FillLayer {
opacity: st.opacity,
blend: st.blend,
effects: st.effects.clone(),
..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone())
};
n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], effects: vec![] };
Some(n)
}
/// Path `l` with every stroke outlined, in appearance order: runs of fills stay on copies of
/// the path, each stroke becomes its outline (or brush art), grouped when there are several.
/// `None` when `l` has no stroke to outline.
fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option<Node> {
if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) {
return None;
}
let (path, rule) = node_path(l)?;
let mut parts: Vec<Node> = vec![];
let mut fills: Vec<AppearanceItem> = vec![];
let fill_node = |d: &mut Document, items: Vec<AppearanceItem>| {
let mut f = d.reid(l);
f.appearance = Appearance { items, effects: vec![] };
f.opacity = 1.0;
f.blend = BlendMode::Normal;
f.name = None;
f.mask = None;
if let NodeKind::Path { live, .. } = &mut f.kind {
*live = None;
}
f
};
for item in &l.appearance.items {
match item {
AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => fills.push(item.clone()),
AppearanceItem::Stroke(st) if outlinable(st) => {
if !fills.is_empty() {
parts.push(fill_node(d, std::mem::take(&mut fills)));
}
parts.extend(outlined_stroke(d, brushes, &path, rule, st));
}
_ => {}
} }
_ => po::outline_stroke(path, w, cap, join, st.miter_limit),
} }
if !fills.is_empty() {
parts.push(fill_node(d, fills));
}
let root = match parts.len() {
0 => return None,
1 => parts.remove(0),
_ => Node::group(d.alloc_id(), parts.into_iter().map(Arc::new).collect()),
};
let mut root = with_transparency(d, root, l.opacity, l.blend);
root.name = l.name.clone();
root.mask = l.mask.clone();
root.appearance.effects = l.appearance.effects.clone();
Some(root)
} }
fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> { fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?; let roots = selected_roots(s)?;
let ids = s.edit("Outline Stroke", |d, sel| { let ids = s.edit("Outline Stroke", |d, sel| {
let brushes = vectorcraft_brush::library(d);
let mut new_sel = vec![]; let mut new_sel = vec![];
let mut changed = 0; let mut changed = 0;
for root in &roots { for root in &roots {
@ -496,43 +562,7 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
if matches!(l.kind, NodeKind::Text(_)) { if matches!(l.kind, NodeKind::Text(_)) {
continue; continue;
} }
let Some(st) = l.appearance.stroke().filter(|s| s.visible && !s.paint.is_none() && s.width > 0.0).cloned() else { continue }; let Some(new) = outline_strokes(d, &brushes, &l) else { continue };
let Some((path, rule)) = node_path(&l) else { continue };
let outline = stroke_outline(&path, rule, &st);
if outline.is_empty() {
continue;
}
let mut stroke_node = shape_node(d, outline, Some(&l));
stroke_node.appearance =
Appearance { items: vec![AppearanceItem::Fill(vectorcraft_doc::appearance::FillLayer::new(st.paint.clone()))], effects: vec![] };
let fill = l.appearance.fill().filter(|f| f.visible && !f.paint.is_none()).cloned();
let new = if let Some(f) = fill {
let mut fill_node = l.clone();
fill_node.id = d.alloc_id();
if let Some(ch) = fill_node.children_mut() {
for c in ch.iter_mut() {
Arc::make_mut(c).id = d.alloc_id();
}
}
fill_node.appearance = Appearance { items: vec![AppearanceItem::Fill(f)], effects: vec![] };
if let NodeKind::Path { live, .. } = &mut fill_node.kind {
*live = None;
}
let gid = d.alloc_id();
let mut g = Node::group(gid, vec![Arc::new(fill_node), Arc::new(stroke_node)]);
g.opacity = l.opacity;
g.blend = l.blend;
if let Some(ch) = g.children_mut() {
for c in ch.iter_mut() {
let c = Arc::make_mut(c);
c.opacity = 1.0;
c.blend = BlendMode::Normal;
}
}
g
} else {
stroke_node
};
let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?; let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?;
let nid = d.insert(par, idx, new)?; let nid = d.insert(par, idx, new)?;
d.remove(l.id)?; d.remove(l.id)?;

View File

@ -3,7 +3,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint}; use vectorcraft_color::{Color, Paint, keep_model};
use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use super::*; use super::*;
@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork", "Recolor Artwork",
["Edit", "Edit Colors"], ["Edit", "Edit Colors"],
None, None,
"{map: {\"#rrggbb\": \"#rrggbb\", …}} replace colours across the selection (gradients and text included)", "{map: {\"#rrggbb\": \"#rrggbb\", …}} replace colours across the selection (gradients and text included); each new colour keeps the model of the one it replaces",
has_selection, has_selection,
apply apply
), ),
@ -81,7 +81,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(n) = d.node_mut(*id) { if let Some(n) = d.node_mut(*id) {
visit_paints(n, &mut |c| { visit_paints(n, &mut |c| {
if let Some(to) = map.get(&c.to_hex()) { if let Some(to) = map.get(&c.to_hex()) {
*c = *to; *c = keep_model(*c, *to);
changed += 1; changed += 1;
} }
}); });

View File

@ -75,6 +75,24 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("select.object.openPaths", "Open Paths", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n cmd!("select.object.openPaths", "Open Paths", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n
.path_data() .path_data()
.is_some_and(|p| !p.is_closed()))), .is_some_and(|p| !p.is_closed()))),
cmd!(
"select.same.graphicStyle",
"Graphic Style",
["Select", "Same"],
None,
"{} the objects linked to the graphic style the first selected object is linked to",
has_selection,
same_graphic_style
),
cmd!(
"select.same.appearanceAttribute",
"Appearance Attribute",
["Select", "Same"],
None,
"{item?: appearance item index} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill",
has_selection,
same_attribute
),
] ]
} }
@ -224,20 +242,68 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Value> { fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Value> {
let st = s.doc()?; let st = s.doc()?;
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?; let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
select_where(s, cmd, &json!({}), |_, n| !n.is_container() && eq(n, &refn))
}
/// Select the visible, unlocked objects in visible, unlocked layers that `f` accepts (not looking
/// inside accepted ones); Select > Reselect repeats `cmd` with `p`.
fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result<Value> {
fn visit(d: &Document, n: &Node, f: &impl Fn(&Document, &Node) -> bool, ids: &mut Vec<NodeId>) {
if n.visible && !n.locked && !n.is_layer() && f(d, n) {
return ids.push(n.id);
}
for c in n.children().into_iter().flatten() {
visit(d, c, f, ids);
}
}
let st = s.doc()?;
let mut ids = vec![]; let mut ids = vec![];
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) { for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
l.walk(&mut |n| { visit(&st.doc, l, &f, &mut ids);
if !n.is_container() && n.visible && !n.locked && eq(n, &refn) {
ids.push(n.id);
}
});
} }
let c = cmd.to_string();
s.select(|_, sel| sel.set(ids.iter().copied()))?; s.select(|_, sel| sel.set(ids.iter().copied()))?;
s.doc_mut()?.last_selection_cmd = Some((c, json!({}))); s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone()));
Ok(json!({ "count": ids.len() })) Ok(json!({ "count": ids.len() }))
} }
fn same_graphic_style(s: &mut Session, _: &Value) -> Result<Value> {
let cmd = "select.same.graphicStyle";
let st = s.doc()?;
let id = st
.selection
.objects
.first()
.and_then(|id| super::style::linked_style(&st.doc, st.doc.node(*id)?))
.map(|g| g.id)
.ok_or_else(|| bad(cmd, "the selection has no graphic style"))?;
select_where(s, cmd, &json!({}), |d, n| n.graphic_style == Some(id) && super::style::linked_style(d, n).is_some())
}
/// An appearance attribute Select > Same > Appearance Attribute matches.
enum Attribute {
Item(vectorcraft_doc::AppearanceItem),
Effect(vectorcraft_doc::Effect),
}
fn same_attribute(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "select.same.appearanceAttribute";
let st = s.doc()?;
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?;
let ap = &refn.appearance;
let item = p.get("item").and_then(Value::as_u64).map(|i| i as usize).or(s.appearance_item());
let attr = match (item.and_then(|i| ap.items.get(i)), ap.effects.first()) {
(Some(it), _) => Attribute::Item(it.clone()),
(None, Some(e)) => Attribute::Effect(e.clone()),
(None, None) => {
Attribute::Item(ap.items.iter().rev().find(|i| i.is_fill()).cloned().ok_or_else(|| bad(cmd, "the object has no fill, stroke or effect"))?)
}
};
select_where(s, cmd, p, |_, n| match &attr {
Attribute::Item(it) => n.appearance.items.contains(it),
Attribute::Effect(e) => n.appearance.effects.iter().any(|x| x.id == e.id && x.params == e.params),
})
}
fn same_layers(s: &mut Session, _: &Value) -> Result<Value> { fn same_layers(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?; let st = s.doc()?;
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect(); let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect();

View File

@ -14,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options", "Stroke Options",
["Window", "Stroke"], ["Window", "Stroke"],
None, None,
"{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares), dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?}", "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?}",
has_doc, has_doc,
stroke_set stroke_set
), ),
@ -90,14 +90,19 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Null) => st.dash = None, Some(Value::Null) => st.dash = None,
Some(Value::Array(a)) => { Some(Value::Array(a)) => {
let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect(); let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
st.dash = if pattern.is_empty() { // A new pattern keeps the offset and alignment of the one it replaces.
None st.dash = (!pattern.is_empty()).then(|| Dash { pattern, ..st.dash.take().unwrap_or_default() });
} else {
Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) })
};
} }
_ => {} _ => {}
} }
if let Some(d) = st.dash.as_mut() {
if let Some(o) = p.get("dashOffset").and_then(Value::as_f64) {
d.offset = o;
}
if let Some(a) = p.get("alignDashes").and_then(Value::as_bool) {
d.align_corners = a;
}
}
if let Some(a) = sa { if let Some(a) = sa {
st.start_arrow = a; st.start_arrow = a;
} }

View File

@ -1,34 +1,288 @@
//! The Graphic Styles panel: apply, new, delete and duplicate. //! The Graphic Styles panel: new, apply (replace or add), link, redefine, break link, rename,
//! delete, duplicate, sort and list.
//!
//! Applying a style links the targeted objects to it ([`Node::graphic_style`]). An object stays
//! linked while it keeps the style's look; editing its appearance or transparency breaks the link
//! ([`in_sync`]), and Redefine Graphic Style updates only the objects still linked.
//!
//! Styles keep placed gradients relative to the unit box ([`GraphicStyle::unit_box`]): a new or
//! redefined style stores them relative to its source's bounds, and each object a style is applied
//! to gets them at the same place relative to its own.
use std::collections::HashMap;
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind};
use vectorcraft_geom::Rect;
use super::*; use super::*;
use crate::EngineError; use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
cmd!("graphicStyle.apply", "Apply Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_selection, style_apply), cmd!(
cmd!("graphicStyle.new", "New Graphic Style", ["Window", "Graphic Styles"], None, "{name?} from the selection", has_selection, style_new), "graphicStyle.apply",
cmd!("graphicStyle.delete", "Delete Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_delete), "Apply Graphic Style",
cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_duplicate), ["Window", "Graphic Styles"],
None,
"{name, add?: bool, ids?} give the objects (default: the selection; a group's contents) the style's appearance, and themselves its opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks",
has_doc,
style_apply
),
cmd!(
"graphicStyle.new",
"New Graphic Style",
["Window", "Graphic Styles"],
None,
"{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}",
has_doc,
style_new
),
cmd!(
"graphicStyle.delete",
"Delete Graphic Style",
["Window", "Graphic Styles"],
None,
"{name} | {names: [name…]} delete styles; their objects keep their look but are unlinked",
has_doc,
style_delete
),
cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name} → {name}", has_doc, style_duplicate),
cmd!(
query "graphicStyle.list",
"List Graphic Styles",
[],
None,
"{} → {styles: [{id, name, fill, stroke, strokeWidth, fills, strokes, effects: [effect id…], opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", linked: [object ids]}], selected: the style the first selected object is linked to, or null}",
has_doc,
style_list
),
cmd!(
"graphicStyle.redefine",
"Redefine Graphic Style",
["Window", "Appearance"],
None,
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked → {name}",
has_doc,
style_redefine
),
cmd!(
"graphicStyle.breakLink",
"Break Link to Graphic Style",
["Window", "Graphic Styles"],
None,
"{ids?} unlink the objects (default: the selection) from their graphic style; they keep their look",
has_doc,
style_break_link
),
cmd!(
"graphicStyle.rename",
"Rename Graphic Style",
["Window", "Graphic Styles"],
None,
"{name, to} (Graphic Style Options; names are unique and linked objects stay linked) → {name}",
has_doc,
style_rename
),
cmd!(
query "graphicStyle.unused",
"Select All Unused",
["Window", "Graphic Styles"],
None,
"{} → {names: [the styles no object is linked to]} (the panel selects them)",
has_doc,
style_unused
),
cmd!(
"graphicStyle.sortByName",
"Sort by Name",
["Window", "Graphic Styles"],
None,
"{} sort the styles by name (the Default Graphic Style stays first)",
has_doc,
style_sort
),
] ]
} }
// ---------- links ----------
/// Does a style applied to `n` (`top`: `n` is the target itself) descend into its children? Groups
/// and layers style their contents, and so do other containers (blends…) inside them, while a
/// compound path takes it as a whole, as with paint commands ([`leaf_targets`]).
fn descends(n: &Node, top: bool) -> bool {
match n.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } => true,
NodeKind::Compound { .. } => false,
_ => !top && n.is_container(),
}
}
/// The objects whose appearance a style applied to `n` sets: `n`, or its painted contents.
fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
match n.children() {
Some(ch) if descends(n, top) => ch.iter().for_each(|c| painted(c, false, out)),
_ => out.push(n),
}
}
/// [`painted`], mutably (copying only the nodes on the way).
fn paint_mut(n: &mut Node, top: bool, f: &mut impl FnMut(&mut Node)) {
if !descends(n, top) {
return f(n);
}
for c in n.children_mut().into_iter().flatten() {
paint_mut(std::sync::Arc::make_mut(c), false, f);
}
}
/// Does `n` still have style `g`'s look (its transparency, and its appearance on every painted
/// object)? Linked objects that don't are no longer linked.
pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool {
let mut leaves = vec![];
painted(n, true, &mut leaves);
g.transparency_matches(n) && leaves.iter().all(|l| g.appearance_on(l).approx_eq(&l.appearance))
}
/// The style `n` is linked to (and still looks like).
pub(crate) fn linked_style<'a>(d: &'a Document, n: &Node) -> Option<&'a GraphicStyle> {
d.graphic_style_by_id(n.graphic_style?).filter(|g| in_sync(n, g))
}
/// Objects anywhere in the art that `f` accepts.
fn objects_where(d: &Document, mut f: impl FnMut(&Node) -> bool) -> Vec<NodeId> {
let mut ids = vec![];
for l in &d.layers {
l.walk(&mut |n| {
if f(n) {
ids.push(n.id);
}
});
}
ids
}
/// The objects linked to each style id.
fn links(d: &Document) -> HashMap<u32, Vec<NodeId>> {
let mut out: HashMap<u32, Vec<NodeId>> = HashMap::new();
for l in &d.layers {
l.walk(&mut |n| {
if let Some(g) = linked_style(d, n) {
out.entry(g.id).or_default().push(n.id);
}
});
}
out
}
/// `ap` with its placed gradients moved from box `from` (an object's bounds) to the unit box.
fn unitized(mut ap: Appearance, from: Option<Rect>) -> Appearance {
if let Some(b) = from.filter(|_| ap.has_placed_gradient()) {
ap.rebase_gradients(b, GraphicStyle::UNIT_BOX);
}
ap
}
/// The appearance a new or redefined style takes from `n`: its own. A group without fills or
/// strokes of its own lends those of its topmost painted object, and type its characters' fill
/// and stroke. Placed gradients come relative to the unit box (from the bounds of the object
/// they're on).
fn captured(n: &Node) -> Appearance {
let mut ap = n.appearance.clone();
if !ap.items.is_empty() {
return unitized(ap, n.geometric_bounds());
}
match &n.kind {
NodeKind::Text(t) => {
if let Some(r) = t.runs.first() {
ap.items = Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width).items;
// Type paints its characters in text space.
ap = unitized(ap, Some(t.local_bounds()));
}
}
_ => {
let mut leaves = vec![];
painted(n, true, &mut leaves);
if let Some(top) = leaves.last().filter(|l| l.id != n.id) {
// The topmost object's own effects apply before the group's.
let top = captured(top);
ap.items = top.items;
ap.effects.splice(0..0, top.effects);
}
}
}
ap
}
/// Give `id` style `g` (its painted objects the appearance, itself the transparency) and link it.
fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle) {
if let Some(n) = d.node_mut(id) {
paint_mut(n, true, &mut |l| l.appearance = g.appearance_on(l).into_owned());
g.apply_transparency(n);
n.graphic_style = Some(g.id);
}
}
fn set_link(d: &mut Document, ids: &[NodeId], link: Option<u32>) {
for id in ids {
if let Some(n) = d.node_mut(*id) {
n.graphic_style = link;
}
}
}
fn style_index(d: &Document, name: &str) -> Result<usize> {
d.graphic_style_index(name).ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))
}
/// The object a style is made from: `id`, else the first selected object.
fn source<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(NodeId, &'a Node)> {
let id = match id_param(p, "id") {
Some(id) => id,
None => super::edit::selected_roots(s)?.first().copied().ok_or_else(|| bad(cmd, "select an object (or pass `id`)"))?,
};
Ok((id, s.doc()?.doc.node(id).ok_or_else(|| bad(cmd, format!("no object {}", id.0)))?))
}
impl Session {
/// The graphic style of the first selected object: the one it was last linked to, and whether
/// it is still linked (still has that style's look).
pub fn selection_graphic_style(&self) -> Option<(&GraphicStyle, bool)> {
let st = self.active()?;
let n = st.doc.node(*st.selection.objects.first()?)?;
let g = st.doc.graphic_style_by_id(n.graphic_style?)?;
Some((g, in_sync(n, g)))
}
}
// ---------- commands ----------
fn style_apply(s: &mut Session, p: &Value) -> Result<Value> { fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?; let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?;
let ap = s let i = style_index(&s.doc()?.doc, name)?;
.doc()? let add = bool_or(p, "add", false);
.doc let ids = super::appearance::appearance_targets(s, p)?;
.graphic_styles if ids.is_empty() {
.iter() return Err(bad("graphicStyle.apply", "nothing selected (or pass `ids`)"));
.find(|g| g.name == name) }
.map(|g| g.appearance.clone())
.ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?;
let ids = paint_targets(s, p)?;
s.edit("Apply Graphic Style", |d, _| { s.edit("Apply Graphic Style", |d, _| {
for id in &ids { if add {
if let Some(n) = d.node_mut(*id) { let g = d.graphic_styles[i].clone();
n.appearance = ap.clone(); for id in &ids {
if let Some(n) = d.node_mut(*id) {
paint_mut(n, true, &mut |l| {
let ap = g.appearance_on(l).into_owned();
l.appearance.items.extend(ap.items);
l.appearance.effects.extend(ap.effects);
});
n.graphic_style = None;
}
}
} else {
d.graphic_style_id(i);
let g = d.graphic_styles[i].clone();
for id in &ids {
apply_style(d, *id, &g);
} }
} }
Ok(()) Ok(())
@ -37,23 +291,37 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
} }
fn style_new(s: &mut Session, p: &Value) -> Result<Value> { fn style_new(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?; let (src, n) = source(s, p, "graphicStyle.new")?;
let id = *st.selection.objects.first().unwrap(); let d = &s.doc()?.doc;
let ap = st.doc.node(id).map(|n| n.appearance.clone()).unwrap_or_default(); let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Graphic Style {}", st.doc.graphic_styles.len() + 1)); Some(n) => unique_name(n, |x| d.graphic_style(x).is_some()),
None => d.new_graphic_style_name(),
};
let g = GraphicStyle { id: d.next_graphic_style_id(), ..GraphicStyle::of(name.clone(), captured(n), n) };
s.edit("New Graphic Style", |d, _| { s.edit("New Graphic Style", |d, _| {
d.graphic_styles.push(vectorcraft_doc::GraphicStyle { name: name.clone(), appearance: ap }); set_link(d, &[src], Some(g.id));
d.graphic_styles.push(g);
Ok(()) Ok(())
})?; })?;
Ok(json!({ "name": name })) Ok(json!({ "name": name }))
} }
fn style_delete(s: &mut Session, p: &Value) -> Result<Value> { fn style_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.delete", "missing name"))?.to_string(); let names: Vec<String> = match (p.get("names").and_then(Value::as_array), str_param(p, "name")) {
s.edit("Delete Graphic Style", |d, _| { (Some(a), _) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
let before = d.graphic_styles.len(); (None, Some(n)) => vec![n.to_string()],
d.graphic_styles.retain(|g| g.name != name); (None, None) => return Err(bad("graphicStyle.delete", "missing name")),
if d.graphic_styles.len() == before { Err(EngineError::Other(format!("no graphic style `{name}`"))) } else { Ok(()) } };
s.edit(if names.len() > 1 { "Delete Graphic Styles" } else { "Delete Graphic Style" }, |d, _| {
for name in &names {
let i = style_index(d, name)?;
let id = d.graphic_styles.remove(i).id;
if id != 0 {
let ids = objects_where(d, |n| n.graphic_style == Some(id));
set_link(d, &ids, None);
}
}
Ok(())
})?; })?;
ok() ok()
} }
@ -61,12 +329,112 @@ fn style_delete(s: &mut Session, p: &Value) -> Result<Value> {
fn style_duplicate(s: &mut Session, p: &Value) -> Result<Value> { fn style_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.duplicate", "missing name"))?.to_string(); let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.duplicate", "missing name"))?.to_string();
let new = s.edit("Duplicate Graphic Style", |d, _| { let new = s.edit("Duplicate Graphic Style", |d, _| {
let pos = d.graphic_styles.iter().position(|g| g.name == name).ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?; let pos = style_index(d, &name)?;
let mut g = d.graphic_styles[pos].clone(); let nm = unique_name(&format!("{name} copy"), |n| d.graphic_style(n).is_some());
let nm = unique_name(&format!("{name} copy"), |n| d.graphic_styles.iter().any(|x| x.name == n)); let g = GraphicStyle { name: nm.clone(), id: d.next_graphic_style_id(), ..d.graphic_styles[pos].clone() };
g.name = nm.clone();
d.graphic_styles.insert(pos + 1, g); d.graphic_styles.insert(pos + 1, g);
Ok(nm) Ok(nm)
})?; })?;
Ok(json!({"name": new})) Ok(json!({"name": new}))
} }
fn style_list(s: &mut Session, _: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let links = links(d);
let styles: Vec<Value> = d
.graphic_styles
.iter()
.map(|g| {
let ap = &g.appearance;
let count = |fill: bool| ap.items.iter().filter(|i| i.is_fill() == fill).count();
json!({
"id": g.id,
"name": g.name,
"fill": ap.fill_paint().label(),
"stroke": ap.stroke_paint().label(),
"strokeWidth": ap.stroke_width(),
"fills": count(true),
"strokes": count(false),
"effects": ap.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(),
"opacity": (g.opacity * 100.0).round(),
"blend": g.blend.label(),
"isolate": g.isolate,
"knockout": g.knockout.label(),
"linked": links.get(&g.id).map(|v| v.iter().map(|i| i.0).collect::<Vec<_>>()).unwrap_or_default(),
})
})
.collect();
let selected = s.selection_graphic_style().filter(|(_, linked)| *linked).map(|(g, _)| g.name.clone());
Ok(json!({ "styles": styles, "selected": selected }))
}
fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "graphicStyle.redefine";
let (src, n) = source(s, p, cmd)?;
let d = &s.doc()?.doc;
let i = match str_param(p, "name") {
Some(name) => style_index(d, name)?,
None => n
.graphic_style
.and_then(|id| d.graphic_style_by_id(id))
.and_then(|g| d.graphic_style_index(&g.name))
.ok_or_else(|| bad(cmd, "the object has no graphic style (pass `name`)"))?,
};
let look = GraphicStyle::of(String::new(), captured(n), n);
let name = d.graphic_styles[i].name.clone();
s.edit(&format!("Redefine Graphic Style \u{201c}{name}\u{201d}"), |d, _| {
let id = d.graphic_style_id(i);
let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look });
// Objects still linked follow the new definition; edited ones are unlinked (they keep
// their look), and the source keeps its own.
let linked = objects_where(d, |n| n.graphic_style == Some(id) && n.id != src);
let (follow, edited): (Vec<NodeId>, Vec<NodeId>) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old)));
set_link(d, &edited, None);
let g = d.graphic_styles[i].clone();
for nid in follow {
apply_style(d, nid, &g);
}
set_link(d, &[src], Some(id));
Ok(())
})?;
Ok(json!({ "name": name }))
}
fn style_break_link(s: &mut Session, p: &Value) -> Result<Value> {
let ids = super::appearance::appearance_targets(s, p)?;
s.edit("Break Link to Graphic Style", |d, _| {
set_link(d, &ids, None);
Ok(())
})?;
ok()
}
fn style_rename(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "graphicStyle.rename";
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?.to_string();
let to = str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad(cmd, "missing `to`"))?.to_string();
s.edit("Graphic Style Options", |d, _| {
let i = style_index(d, &name)?;
if to != name && d.graphic_style(&to).is_some() {
return Err(EngineError::Other(format!("a graphic style named `{to}` already exists")));
}
d.graphic_styles[i].name = to.clone();
Ok(())
})?;
Ok(json!({ "name": to }))
}
fn style_unused(s: &mut Session, _: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let links = links(d);
let names: Vec<&str> = d.graphic_styles.iter().filter(|g| !links.contains_key(&g.id)).map(|g| g.name.as_str()).collect();
Ok(json!({ "names": names }))
}
fn style_sort(s: &mut Session, _: &Value) -> Result<Value> {
s.edit("Sort by Name", |d, _| {
d.graphic_styles.sort_by_cached_key(|g| (g.name != DEFAULT_GRAPHIC_STYLE, g.name.to_lowercase()));
Ok(())
})?;
ok()
}

View File

@ -59,6 +59,60 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc, has_doc,
swatch_list swatch_list
), ),
cmd!(
"swatch.move",
"Move Swatch",
["Window", "Swatches"],
None,
"{name? | names?: [swatch names, in order; or colour group names to reorder the groups], to?: index in the destination (they go before the swatch or group now there; default: the end), group?: destination colour group (default: the ungrouped swatches; groups hold solid colours only)} reorder swatches or move them into or out of colour groups, as one undo step → {moved}",
has_doc,
swatch_move
),
cmd!(
"swatch.addUsedColors",
"Add Used Colors",
["Window", "Swatches"],
None,
"{selection?: false (only the selected art's colours; default: all the art's), global?: false (the new swatches are global and the matching unlinked colours in that art link to them)} add a swatch for each colour used (fills, strokes, text, gradient stops, mesh points) that no solid swatch has yet, as one undo step → {added: [names], linked}",
has_doc,
swatch_add_used
),
cmd!(
query "swatch.unused",
"Select All Unused",
["Window", "Swatches"],
None,
"{} → {names: [the swatches nothing uses, in panel order]}: a colour is used when a paint links to it or an unlinked paint, gradient stop or mesh point has its colour; a gradient when a gradient links to it or has its stops; a pattern when it fills or strokes anything (art, symbols, pattern tiles and graphic styles count). None is never listed",
has_doc,
swatch_unused
),
cmd!(
"swatch.merge",
"Merge Swatches",
["Window", "Swatches"],
None,
"{names: [two or more solid-colour swatches]} keep the first (its name and colour) and delete the others; paints linked to them take its colour and link to it (or unlink when it isn't global), as one undo step → {name, merged: [deleted names], relinked}",
has_doc,
swatch_merge
),
cmd!(
"swatch.ungroup",
"Ungroup Color Group",
["Window", "Swatches"],
None,
"{name: colour group} move its swatches to the end of the ungrouped swatches and remove the group, as one undo step → {swatches: [names]}",
has_doc,
swatch_ungroup
),
cmd!(
"swatch.sortByKind",
"Sort by Kind",
["Window", "Swatches"],
None,
"{} order the swatches of each list by kind (None first, then process colours, spot colours, gradients, patterns; colour groups stay after them), keeping their order within a kind, as one undo step",
has_doc,
swatch_sort_by_kind
),
] ]
} }
@ -101,8 +155,8 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "name": name })) Ok(json!({ "name": name }))
} }
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> { /// The `names` list and `name` parameters, without repeats; at least one is required.
const C: &str = "swatch.delete"; fn names_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
let mut names: Vec<String> = vec![]; let mut names: Vec<String> = vec![];
for n in str_list(p, "names").into_iter().chain(str_param(p, "name").map(str::to_string)) { for n in str_list(p, "names").into_iter().chain(str_param(p, "name").map(str::to_string)) {
if !names.contains(&n) { if !names.contains(&n) {
@ -110,8 +164,14 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
} }
} }
if names.is_empty() { if names.is_empty() {
return Err(bad(C, "give `name` or `names`")); return Err(bad(cmd, "give `name` or `names`"));
} }
Ok(names)
}
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.delete";
let names = names_param(p, C)?;
let d = &s.doc()?.doc; let d = &s.doc()?.doc;
// Global swatches going away (named ones and those in named groups): their links are dropped. // Global swatches going away (named ones and those in named groups): their links are dropped.
let mut global: Vec<String> = vec![]; let mut global: Vec<String> = vec![];
@ -161,6 +221,27 @@ fn artwork_colors(d: &Document, ids: &[NodeId], tints: bool, swatches: &mut Vec<
} }
} }
/// Link the solid paints in the subtrees of `ids` whose colour is one of `links`' to that swatch,
/// unless they already link to an existing swatch. Returns the number of paints linked.
fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> usize {
if links.is_empty() {
return 0;
}
let live: Vec<String> = d.swatches_iter().map(|w| w.name.clone()).collect();
d.map_solid_paints_in(ids, &mut |c, link| {
if link.as_ref().is_some_and(|l| live.contains(l)) {
return false;
}
match links.iter().find(|(lc, _)| lc == c) {
Some((_, n)) => {
*link = Some(n.clone());
true
}
None => false,
}
})
}
fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> { fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.newGroup"; const C: &str = "swatch.newGroup";
let mut names = str_list(p, "swatches"); let mut names = str_list(p, "swatches");
@ -201,28 +282,73 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
} }
let members: Vec<String> = group.swatches.iter().map(|w| w.name.clone()).collect(); let members: Vec<String> = group.swatches.iter().map(|w| w.name.clone()).collect();
d.swatch_groups.push(group); d.swatch_groups.push(group);
let linked = if links.is_empty() { let linked = link_colors(d, &sel.objects, &links);
0
} else {
let live: Vec<String> = d.swatches_iter().map(|w| w.name.clone()).collect();
d.map_solid_paints_in(&sel.objects, &mut |c, link| {
if link.as_ref().is_some_and(|l| live.contains(l)) {
return false;
}
match links.iter().find(|(lc, _)| lc == c) {
Some((_, n)) => {
*link = Some(n.clone());
true
}
None => false,
}
})
};
Ok((name, members, linked)) Ok((name, members, linked))
})?; })?;
Ok(json!({"name": name, "swatches": members, "linked": linked})) Ok(json!({"name": name, "swatches": members, "linked": linked}))
} }
/// The name of the item that items moving to index `to` of `list` go before: the first one at or
/// after `to` that isn't moving (`None`: the end).
fn anchor<T>(list: &[T], to: Option<usize>, moving: &[String], name: impl Fn(&T) -> &str) -> Option<String> {
list.iter().skip(to?).map(&name).find(|n| !moving.iter().any(|m| m == n)).map(str::to_string)
}
/// Insert `items` into `list` before the item named `anchor` (at the end without one).
fn insert_before<T>(list: &mut Vec<T>, items: Vec<T>, anchor: Option<&str>, name: impl Fn(&T) -> &str) {
let at = anchor.and_then(|a| list.iter().position(|x| name(x) == a)).unwrap_or(list.len());
list.splice(at..at, items);
}
fn swatch_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.move";
let names = names_param(p, C)?;
let to = p.get("to").and_then(Value::as_u64).map(|v| v as usize);
let group = str_param(p, "group").map(str::to_string);
let moved = names.len();
s.edit("Move Swatch", |d, _| {
let is_group = |d: &Document, n: &str| d.swatch_groups.iter().any(|g| g.name == n);
if names.iter().all(|n| is_group(d, n)) {
if group.is_some() {
return Err(bad(C, "colour groups can't go into colour groups"));
}
let at = anchor(&d.swatch_groups, to, &names, |g| &g.name);
let mut items = vec![];
for n in &names {
if let Some(i) = d.swatch_groups.iter().position(|g| g.name == *n) {
items.push(d.swatch_groups.remove(i));
}
}
insert_before(&mut d.swatch_groups, items, at.as_deref(), |g| &g.name);
return Ok(());
}
for n in &names {
let sw = d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}` (swatches and colour groups move separately)")))?;
if sw.paint.is_none() {
return Err(bad(C, format!("`{n}` can't be moved")));
}
if group.is_some() && sw.paint.color().is_none() {
return Err(bad(C, format!("`{n}`: colour groups hold solid colours only")));
}
}
let dest = match &group {
Some(g) => Some(d.swatch_groups.iter().position(|x| x.name == *g).ok_or_else(|| bad(C, format!("no colour group `{g}`")))?),
None => None,
};
fn list(d: &mut Document, dest: Option<usize>) -> &mut Vec<Swatch> {
match dest {
Some(i) => &mut d.swatch_groups[i].swatches,
None => &mut d.swatches,
}
}
let at = anchor(list(d, dest), to, &names, |w| &w.name);
let items: Vec<Swatch> = names.iter().filter_map(|n| d.remove_swatch(n)).collect();
insert_before(list(d, dest), items, at.as_deref(), |w| &w.name);
Ok(())
})?;
Ok(json!({ "moved": moved }))
}
fn swatch_duplicate(s: &mut Session, p: &Value) -> Result<Value> { fn swatch_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string(); let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string();
let new = s.edit("Duplicate Swatch", |d, _| { let new = s.edit("Duplicate Swatch", |d, _| {
@ -405,3 +531,120 @@ fn swatch_sort(s: &mut Session, _: &Value) -> Result<Value> {
})?; })?;
ok() ok()
} }
fn swatch_add_used(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.addUsedColors";
let global = bool_or(p, "global", false);
let only_selection = bool_or(p, "selection", false);
let (added, linked) = s.edit("Add Used Colors", |d, sel| {
let ids: Vec<NodeId> = if only_selection { sel.objects.clone() } else { d.layers.iter().map(|l| l.id).collect() };
if only_selection && ids.is_empty() {
return Err(bad(C, "select artwork to add its colours"));
}
let (mut linked_to, mut colors) = (vec![], vec![]);
artwork_colors(d, &ids, false, &mut linked_to, &mut colors);
// Colours a solid swatch already has are skipped.
colors.retain(|c| !d.swatches_iter().any(|w| w.paint.color() == Some(*c)));
let mut links = vec![];
for c in colors {
let name = d.free_swatch_name(&color_name(c));
d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(c), global, spot: false });
links.push((c, name));
}
let linked = if global { link_colors(d, &ids, &links) } else { 0 };
Ok((links.into_iter().map(|(_, n)| n).collect::<Vec<_>>(), linked))
})?;
Ok(json!({"added": added, "linked": linked}))
}
fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let (mut links, mut colors, mut gradients, mut patterns) = (vec![], vec![], vec![], vec![]);
d.visit_paints(&mut |p| match p {
Paint::Solid { swatch: Some(l), .. } if d.swatch(l).is_some() => links.push(l.clone()),
Paint::Solid { color, .. } => colors.push(*color),
Paint::Gradient(g) => {
links.extend(g.swatch.clone());
colors.extend(g.gradient.stops.iter().map(|s| s.color));
gradients.push(g.gradient.clone());
}
Paint::Pattern { pattern, .. } => patterns.push(pattern.clone()),
Paint::None => {}
});
let used = |w: &Swatch| {
links.contains(&w.name)
|| match &w.paint {
Paint::None => true,
Paint::Solid { color, .. } => colors.contains(color),
Paint::Gradient(g) => gradients.contains(&g.gradient),
Paint::Pattern { pattern, .. } => patterns.contains(pattern),
}
};
let names: Vec<&str> = d.swatches_iter().filter(|w| !used(w)).map(|w| w.name.as_str()).collect();
Ok(json!({ "names": names }))
}
fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.merge";
let names = names_param(p, C)?;
let d = &s.doc()?.doc;
for n in &names {
if d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}`")))?.paint.color().is_none() {
return Err(bad(C, format!("`{n}` isn't a solid colour")));
}
}
let (keep, merged) = names.split_first().ok_or_else(|| bad(C, "give two or more swatches"))?;
if merged.is_empty() {
return Err(bad(C, "give two or more swatches"));
}
let kept = d.swatch(keep).ok_or_else(|| bad(C, format!("no swatch `{keep}`")))?;
let (color, link) = (kept.paint.color().unwrap_or_default(), kept.global.then(|| keep.clone()));
let mut relink = |c: &mut Color, l: &mut Option<String>| {
if !l.as_ref().is_some_and(|l| merged.contains(l)) {
return false;
}
*c = color;
*l = link.clone();
true
};
let relinked = s.edit("Merge Swatches", |d, _| {
for n in merged {
d.remove_swatch(n);
}
Ok(d.map_solid_paints(&mut relink))
})?;
map_default_paints(s, &mut relink);
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
}
fn swatch_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.ungroup";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string();
let moved = s.edit("Ungroup Color Group", |d, _| {
let i = d.swatch_groups.iter().position(|g| g.name == name).ok_or_else(|| bad(C, format!("no colour group `{name}`")))?;
let g = d.swatch_groups.remove(i);
let names: Vec<String> = g.swatches.iter().map(|w| w.name.clone()).collect();
d.swatches.extend(g.swatches);
Ok(names)
})?;
Ok(json!({ "swatches": moved }))
}
fn swatch_sort_by_kind(s: &mut Session, _: &Value) -> Result<Value> {
let rank = |w: &Swatch| match &w.paint {
Paint::None => 0,
Paint::Solid { .. } if !w.spot => 1,
Paint::Solid { .. } => 2,
Paint::Gradient(_) => 3,
Paint::Pattern { .. } => 4,
};
s.edit("Sort Swatches", |d, _| {
// A stable sort: swatches keep their order within a kind.
d.swatches.sort_by_key(rank);
for g in &mut d.swatch_groups {
g.swatches.sort_by_key(rank);
}
Ok(())
})?;
ok()
}

View File

@ -3,6 +3,7 @@
use std::sync::Arc; use std::sync::Arc;
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Node, NodeId, NodeKind}; use vectorcraft_doc::{Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use vectorcraft_geom::{Affine, Point, Rect, Vec2};
@ -26,10 +27,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Sample Color", "Sample Color",
[], [],
None, None,
"{color, stroke?: bool (default: whichever proxy is active), ids?} sample a colour into the active fill/stroke (and the selection)", "{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient)",
has_doc, has_doc,
sample_color sample_color
), ),
cmd!(
"eyedropper.setOptions",
"Eyedropper Options",
[],
None,
"{appearance?: bool (fill, stroke and effects), transparency?: bool (opacity and blend mode)} what the Eyedropper copies (appearance.copyFrom); {} reads them → {appearance, transparency}",
always,
eyedropper_options
),
cmd!( cmd!(
"artboard.move", "artboard.move",
"Move Artboard", "Move Artboard",
@ -161,14 +171,57 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- eyedropper ---------- // ---------- eyedropper ----------
/// What the Eyedropper copies from the object it clicks (`appearance.copyFrom`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EyedropperOptions {
/// The fill, stroke and effects (the appearance stack; type: its run paints).
pub appearance: bool,
/// The opacity and blend mode.
pub transparency: bool,
}
impl Default for EyedropperOptions {
fn default() -> Self {
Self { appearance: true, transparency: true }
}
}
impl EyedropperOptions {
/// These options with any `appearance` / `transparency` booleans in `p` applied.
pub(crate) fn with(mut self, p: &Value) -> Self {
self.appearance = bool_or(p, "appearance", self.appearance);
self.transparency = bool_or(p, "transparency", self.transparency);
self
}
}
fn eyedropper_options(s: &mut Session, p: &Value) -> Result<Value> {
s.eyedropper = s.eyedropper.with(p);
Ok(serde_json::to_value(s.eyedropper).unwrap_or_default())
}
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> { fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.sampleColor";
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
let c = p.get("color").ok_or_else(|| bad("paint.sampleColor", "missing color"))?; let c = p.get("color").ok_or_else(|| bad(C, "missing color"))?;
let mut q = json!({ "color": c }); let (id, mut q) = match p.get("stop") {
None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })),
Some(v) => {
let i = v.as_u64().ok_or_else(|| bad(C, "`stop` must be a stop index"))? as usize;
let color = color_value(c).ok_or_else(|| bad(C, format!("bad color {c}")))?;
let (fill, stroke_paint) = s.proxy_paints();
let Paint::Gradient(g) = (if stroke { stroke_paint } else { fill }) else {
return Err(bad(C, "the paint behind the proxy is not a gradient"));
};
let mut stops = g.gradient.stops;
let n = stops.len();
stops.get_mut(i).ok_or_else(|| bad(C, format!("no stop {i} (the gradient has {n})")))?.color = color;
("paint.editGradient", json!({ "stops": vectorcraft_tools::params::stops_json(&stops), "stroke": stroke }))
}
};
if let Some(ids) = p.get("ids") { if let Some(ids) = p.get("ids") {
q["ids"] = ids.clone(); q["ids"] = ids.clone();
} }
let id = if stroke { "paint.setStroke" } else { "paint.setFill" };
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
(spec.run)(s, &q) (spec.run)(s, &q)
} }

View File

@ -18,6 +18,7 @@ use vectorcraft_doc::{Document, NodeId, NodeKind, Selection};
use vectorcraft_geom::Affine; use vectorcraft_geom::Affine;
use vectorcraft_tools::{PaintDefaults, Tool}; use vectorcraft_tools::{PaintDefaults, Tool};
pub use cmd::EyedropperOptions;
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use tooling::{UiRequest, ViewInfo}; pub use tooling::{UiRequest, ViewInfo};
@ -459,6 +460,8 @@ pub struct Session {
pub recent_colors: Vec<Color>, pub recent_colors: Vec<Color>,
/// A paint applied by a live preview: remembered when the interaction commits. /// A paint applied by a live preview: remembered when the interaction commits.
pub(crate) pending_paint: Option<Paint>, pub(crate) pending_paint: Option<Paint>,
/// What the Eyedropper copies (`eyedropper.setOptions`); not saved.
pub eyedropper: EyedropperOptions,
} }
impl Default for Session { impl Default for Session {
@ -490,6 +493,7 @@ impl Session {
last_gradient: GradientPaint::new(Default::default()), last_gradient: GradientPaint::new(Default::default()),
recent_colors: vec![], recent_colors: vec![],
pending_paint: None, pending_paint: None,
eyedropper: Default::default(),
} }
} }
@ -746,20 +750,30 @@ mod tests_cmdsplit;
#[cfg(test)] #[cfg(test)]
mod tests_colormgmt; mod tests_colormgmt;
#[cfg(test)] #[cfg(test)]
mod tests_dashalign;
#[cfg(test)]
mod tests_distort; mod tests_distort;
#[cfg(test)] #[cfg(test)]
mod tests_draw2; mod tests_draw2;
#[cfg(test)] #[cfg(test)]
mod tests_effectedit;
#[cfg(test)]
mod tests_file; mod tests_file;
#[cfg(test)] #[cfg(test)]
mod tests_gradient; mod tests_gradient;
#[cfg(test)] #[cfg(test)]
mod tests_gradpanel;
#[cfg(test)]
mod tests_knockout;
#[cfg(test)]
mod tests_live; mod tests_live;
#[cfg(test)] #[cfg(test)]
mod tests_menucmds; mod tests_menucmds;
#[cfg(test)] #[cfg(test)]
mod tests_opacitymask; mod tests_opacitymask;
#[cfg(test)] #[cfg(test)]
mod tests_outlinestroke;
#[cfg(test)]
mod tests_paintproxy; mod tests_paintproxy;
#[cfg(test)] #[cfg(test)]
mod tests_panelcmds; mod tests_panelcmds;
@ -772,6 +786,10 @@ mod tests_prefs;
#[cfg(test)] #[cfg(test)]
mod tests_strokegeom; mod tests_strokegeom;
#[cfg(test)] #[cfg(test)]
mod tests_styles;
#[cfg(test)]
mod tests_swatchcmds;
#[cfg(test)]
mod tests_swatches; mod tests_swatches;
#[cfg(test)] #[cfg(test)]
mod tests_textedit; mod tests_textedit;

View File

@ -384,7 +384,7 @@ fn item_transparency_is_one_step_and_checks_the_blend_mode() {
let before = steps(&s); let before = steps(&s);
run(&mut s, "transparency.set", json!({"knockout": true})); run(&mut s, "transparency.set", json!({"knockout": true}));
assert_eq!(steps(&s), before + 1, "isolate/knockout alone do not touch the item"); assert_eq!(steps(&s), before + 1, "isolate/knockout alone do not touch the item");
assert!(node(&s, id).knockout); assert_eq!(node(&s, id).knockout, vectorcraft_doc::Knockout::On);
assert!(s.execute("transparency.set", &json!({"blend": "nope"})).is_err()); assert!(s.execute("transparency.set", &json!({"blend": "nope"})).is_err());
assert_eq!(stroke(&node(&s, id), 1).blend, BlendMode::Normal); assert_eq!(stroke(&node(&s, id), 1).blend, BlendMode::Normal);
} }

View File

@ -0,0 +1,78 @@
//! Dashes fitted to corners and path ends from `stroke.set` (`alignDashes`, `dashOffset`), and
//! Outline Stroke of fitted dashes.
use kurbo::Shape;
use serde_json::json;
use vectorcraft_doc::Dash;
use super::tests_outlinestroke::{outline, path_of};
use super::*;
fn session_with_rect() -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 100, "height": 50})).unwrap();
(s, NodeId(r["id"].as_u64().unwrap()))
}
fn dash_of(s: &Session, id: NodeId) -> Option<Dash> {
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().dash.clone()
}
fn set(s: &mut Session, p: serde_json::Value) {
s.execute("stroke.set", &p).unwrap();
}
#[test]
fn align_dashes_and_the_offset_edit_the_pattern_in_place() {
let (mut s, id) = session_with_rect();
// Without a dash pattern there is nothing to align or offset.
set(&mut s, json!({"alignDashes": true, "dashOffset": 4}));
assert_eq!(dash_of(&s, id), None);
// A new pattern is exact unless asked otherwise.
set(&mut s, json!({"dash": [12, 6], "dashOffset": 3}));
assert_eq!(dash_of(&s, id), Some(Dash { pattern: vec![12.0, 6.0], offset: 3.0, align_corners: false }));
// The flag on its own keeps the pattern and the offset.
set(&mut s, json!({"alignDashes": true}));
assert_eq!(dash_of(&s, id), Some(Dash { pattern: vec![12.0, 6.0], offset: 3.0, align_corners: true }));
// So does a new pattern (as the panel's dash fields send it), and the offset alone.
set(&mut s, json!({"dash": [4, 2]}));
assert_eq!(dash_of(&s, id), Some(Dash { pattern: vec![4.0, 2.0], offset: 3.0, align_corners: true }));
set(&mut s, json!({"dashOffset": 1.5}));
assert_eq!(dash_of(&s, id), Some(Dash { pattern: vec![4.0, 2.0], offset: 1.5, align_corners: true }));
// Each edit is one undo step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(dash_of(&s, id).unwrap().offset, 3.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(dash_of(&s, id).unwrap().pattern, vec![12.0, 6.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!dash_of(&s, id).unwrap().align_corners);
// The params doc tells agents about both modes.
let spec = command_specs().iter().find(|c| c.id == "stroke.set").unwrap();
assert!(spec.params.contains("alignDashes?: bool") && spec.params.contains("dashOffset? (exact dashes only)"));
}
#[test]
fn fitted_dashes_survive_the_native_format() {
let (mut s, id) = session_with_rect();
set(&mut s, json!({"dash": [12, 6], "dashOffset": 2, "alignDashes": true}));
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(back.node(id).unwrap().appearance.stroke().unwrap().dash, dash_of(&s, id));
}
#[test]
fn outline_stroke_of_fitted_dashes_wraps_every_corner() {
let (mut s, id) = session_with_rect();
set(&mut s, json!({"weight": 2, "dash": [12, 6], "alignDashes": true}));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
// The fill, then the outlined dashes.
let ring = path_of(&outline(&mut s).children().unwrap()[1]);
let bp = ring.to_bezpath();
// 5 + 2 + 5 + 2 dashes inside the sides and one wrapped round each corner.
assert_eq!(ring.subpaths.len(), 18);
let half = 6.0 * 100.0 / 108.0;
for (x, y) in [(20.0, 20.0), (120.0, 20.0), (120.0, 70.0), (20.0, 70.0)] {
assert!(bp.contains(kurbo::Point::new(x, y)), "corner ({x}, {y})");
}
assert!(bp.contains(kurbo::Point::new(20.0 + half - 0.3, 20.0)) && !bp.contains(kurbo::Point::new(20.0 + half + 0.3, 20.0)));
}

View File

@ -0,0 +1,121 @@
//! Appearance editing depth: moving and copying effects between the object and its fills and
//! strokes, duplicating and removing several items, Show All Hidden Attributes.
use serde_json::{Value, json};
use vectorcraft_doc::Node;
use super::*;
/// A selected 100×100 rectangle with the default appearance `[Fill white, Stroke black]`.
fn session_with_rect() -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
(s, NodeId(r["id"].as_u64().unwrap()))
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// Effect ids of the object (`None`) or of item `i`.
fn fx(s: &Session, id: NodeId, item: Option<usize>) -> Vec<String> {
node(s, id).appearance.effects_at(item).unwrap().iter().map(|e| e.id.clone()).collect()
}
#[test]
fn effects_reorder_within_a_list_and_undo_in_one_step() {
let (mut s, id) = session_with_rect();
for e in ["distort.twist", "distort.roughen", "stylize.dropShadow"] {
run(&mut s, "effect.apply", json!({ "effect": e }));
}
let r = run(&mut s, "effect.move", json!({"from": 0, "to": 2}));
assert_eq!((r["index"].clone(), r["item"].clone()), (json!(2), Value::Null));
assert_eq!(fx(&s, id, None), ["distort.roughen", "stylize.dropShadow", "distort.twist"]);
// Past the end lands last.
run(&mut s, "effect.move", json!({"from": 0, "to": 9}));
assert_eq!(fx(&s, id, None), ["stylize.dropShadow", "distort.twist", "distort.roughen"]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(fx(&s, id, None), ["distort.twist", "distort.roughen", "stylize.dropShadow"]);
assert!(s.execute("effect.move", &json!({"from": 3, "to": 0})).is_err());
}
#[test]
fn effects_move_and_copy_between_the_object_and_its_items() {
let (mut s, id) = session_with_rect();
run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "item": 1}));
// Into the stroke, above its own effect.
let r = run(&mut s, "effect.move", json!({"from": 0, "fromItem": null, "to": 1, "toItem": 1}));
assert_eq!((r["index"].clone(), r["item"].clone()), (json!(1), json!(1)));
assert!(fx(&s, id, None).is_empty());
assert_eq!(fx(&s, id, Some(1)), ["path.offsetPath", "distort.twist"]);
// Out of the stroke onto the fill, as a copy (Alt-drag): the stroke keeps it.
run(&mut s, "effect.move", json!({"from": 0, "fromItem": 1, "to": 0, "toItem": 0, "copy": true}));
assert_eq!(fx(&s, id, Some(0)), ["path.offsetPath"]);
assert_eq!(fx(&s, id, Some(1)), ["path.offsetPath", "distort.twist"]);
// Back out to the object, one undo step each.
run(&mut s, "effect.move", json!({"from": 1, "fromItem": 1, "to": 0, "toItem": null}));
assert_eq!(fx(&s, id, None), ["distort.twist"]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(fx(&s, id, None).is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert!(fx(&s, id, Some(0)).is_empty());
// A missing item is an error, not a silent no-op.
assert!(s.execute("effect.move", &json!({"from": 0, "fromItem": 1, "to": 0, "toItem": 7})).is_err());
}
#[test]
fn duplicate_selected_works_for_effects() {
let (mut s, id) = session_with_rect();
run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 0}));
run(&mut s, "effect.duplicate", json!({"index": 0, "item": 0}));
assert_eq!(fx(&s, id, Some(0)), ["distort.roughen", "distort.roughen"]);
}
#[test]
fn items_duplicate_to_a_position_and_several_remove_at_once() {
let (mut s, id) = session_with_rect();
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill]
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"}));
// Alt-dragging the top fill to the bottom copies it there.
run(&mut s, "appearance.duplicateItem", json!({"index": 2, "to": 0}));
let kinds = |s: &Session| node(s, id).appearance.items.iter().map(|i| i.kind_name()).collect::<Vec<_>>();
assert_eq!(kinds(&s), ["fill", "fill", "stroke", "fill"]);
assert_eq!(node(&s, id).appearance.items[0].paint(), node(&s, id).appearance.items[3].paint());
// Duplicating several copies each above itself.
run(&mut s, "appearance.duplicateItem", json!({"indices": [0, 2]}));
assert_eq!(kinds(&s), ["fill", "fill", "fill", "stroke", "stroke", "fill"]);
assert!(s.execute("appearance.duplicateItem", &json!({"indices": [0, 1], "to": 3})).is_err());
// Removing several in one step; the active item follows.
run(&mut s, "appearance.setActiveItem", json!({"index": 5}));
run(&mut s, "appearance.removeItem", json!({"indices": [4, 0, 1]}));
assert_eq!(kinds(&s), ["fill", "stroke", "fill"]);
assert_eq!(s.appearance_item(), Some(2));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, id).appearance.items.len(), 6);
}
#[test]
fn show_all_hidden_attributes() {
let (mut s, id) = session_with_rect();
run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1}));
assert!(s.execute("appearance.showAllHidden", &json!({})).is_err(), "nothing hidden yet");
run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false}));
run(&mut s, "effect.setParams", json!({"index": 0, "visible": false, "item": null}));
run(&mut s, "effect.setParams", json!({"index": 0, "visible": false, "item": 1}));
assert!(node(&s, id).appearance.has_hidden());
let r = run(&mut s, "appearance.showAllHidden", json!({}));
assert_eq!(r["ids"], json!([id.0]));
let n = node(&s, id);
assert!(!n.appearance.has_hidden());
assert!(n.appearance.effects[0].visible && n.appearance.items[1].effects()[0].visible && n.appearance.items[0].visible());
s.execute("edit.undo", &json!({})).unwrap();
assert!(node(&s, id).appearance.has_hidden());
}

View File

@ -0,0 +1,168 @@
//! Gradient panel and gradient behaviour: sampling a colour into a gradient stop (M3.33) and
//! copied appearances placing their gradients on the target's own bounds (M3.34) and the
//! Gradient tool's snapping and Eyedropper Options (M3.35).
use serde_json::json;
use vectorcraft_color::{GradientGeom, Paint};
use vectorcraft_geom::Point;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn stop_hexes(s: &Session, id: NodeId, stroke: bool) -> Vec<String> {
let a = &s.doc().unwrap().doc.node(id).unwrap().appearance;
match if stroke { a.stroke_paint() } else { a.fill_paint() } {
Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(),
p => panic!("expected a gradient, got {p:?}"),
}
}
#[test]
fn sample_color_with_a_stop_recolours_that_stop_in_one_undo_step() {
let mut s = session();
let id = rect(&mut s, 10.0, 10.0, 100.0, 100.0);
// A solid fill: no stop to recolour.
let err = s.execute("paint.sampleColor", &json!({"color": "#ff0000", "stop": 0})).unwrap_err();
assert!(err.to_string().contains("not a gradient"), "{err}");
s.execute("paint.setFill", &json!({"gradient": {}})).unwrap();
s.execute("paint.sampleColor", &json!({"color": "#ff0000", "stop": 1})).unwrap();
assert_eq!(stop_hexes(&s, id, false), ["#ffffff", "#ff0000"]);
assert!(s.execute("paint.sampleColor", &json!({"color": "#ff0000", "stop": 2})).unwrap_err().to_string().contains("no stop 2"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(stop_hexes(&s, id, false), ["#ffffff", "#000000"]);
// The stroke's gradient through `stroke`, the fill left alone.
s.execute("paint.setStroke", &json!({"gradient": {}})).unwrap();
s.execute("paint.sampleColor", &json!({"color": "#00ff00", "stop": 0, "stroke": true})).unwrap();
assert_eq!(
(stop_hexes(&s, id, true), stop_hexes(&s, id, false)),
(vec!["#00ff00".into(), "#000000".into()], vec!["#ffffff".into(), "#000000".into()])
);
}
fn fill_geom(s: &Session, id: NodeId) -> Option<GradientGeom> {
match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() {
Paint::Gradient(g) => g.geom,
p => panic!("expected a gradient, got {p:?}"),
}
}
fn close(a: Point, b: Point) -> bool {
a.distance(b) < 1e-9
}
/// A 100 × 100 source at (10, 10) with a placed gradient from 20 % to 80 % across its middle,
/// and a 200 × 100 target at x = 500.
fn source_and_target() -> (Session, NodeId, NodeId) {
let mut s = session();
let src = rect(&mut s, 10.0, 10.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"gradient": {"start": [30, 60], "end": [90, 60]}})).unwrap();
let dst = rect(&mut s, 500.0, 10.0, 200.0, 100.0);
(s, src, dst)
}
#[test]
fn the_eyedropper_places_copied_gradients_on_the_target() {
let (mut s, src, dst) = source_and_target();
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0]})).unwrap();
let g = fill_geom(&s, dst).expect("placed");
assert!(close(g.start, Point::new(540.0, 60.0)) && close(g.end, Point::new(660.0, 60.0)), "{g:?}");
// The source keeps its own; new art fits the gradient to itself.
assert!(close(fill_geom(&s, src).unwrap().start, Point::new(30.0, 60.0)));
assert!(matches!(&s.paint.fill, Paint::Gradient(g) if g.geom.is_none()));
// The eyedropper tool's click runs the same command.
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
let ev = vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Down, 50.0, 50.0);
s.pointer(&ev, ViewInfo::default()).unwrap();
assert!(close(fill_geom(&s, dst).unwrap().start, Point::new(540.0, 60.0)));
}
#[test]
fn graphic_styles_keep_gradients_relative_to_the_bounds() {
let (mut s, src, dst) = source_and_target();
s.execute("select.set", &json!({"ids": [src.0]})).unwrap();
s.execute("graphicStyle.new", &json!({"name": "Glow"})).unwrap();
let style = s.doc().unwrap().doc.graphic_styles.iter().find(|g| g.name == "Glow").cloned().unwrap();
let Paint::Gradient(g) = style.appearance.fill_paint() else { panic!() };
assert!(style.unit_box && close(g.geom.unwrap().start, Point::new(0.2, 0.5)), "stored in unit-box space: {g:?}");
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
s.execute("graphicStyle.apply", &json!({"name": "Glow"})).unwrap();
let g = fill_geom(&s, dst).unwrap();
assert!(close(g.start, Point::new(540.0, 60.0)) && close(g.end, Point::new(660.0, 60.0)), "{g:?}");
// Both stay linked (each has the style's look on its own bounds); redefining the style from an
// edited source moves the new placement onto the linked target's bounds too.
let linked = |s: &mut Session| {
let list = s.execute("graphicStyle.list", &json!({})).unwrap();
list["styles"].as_array().unwrap().iter().find(|g| g["name"] == "Glow").unwrap()["linked"].clone()
};
assert_eq!(linked(&mut s), json!([src.0, dst.0]));
s.execute("select.set", &json!({"ids": [src.0]})).unwrap();
s.execute("paint.setFill", &json!({"gradient": {"start": [13.3, 47.7], "end": [97.1, 71.9]}})).unwrap();
assert_eq!(linked(&mut s), json!([dst.0]), "the edited source is no longer linked");
s.execute("graphicStyle.redefine", &json!({"name": "Glow"})).unwrap();
let g = fill_geom(&s, dst).unwrap();
assert!(close(g.start, Point::new(506.6, 47.7)) && close(g.end, Point::new(674.2, 71.9)), "{g:?}");
assert_eq!(linked(&mut s), json!([src.0, dst.0]));
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
// The flag survives a save; a style from an older file (no flag) keeps document coordinates.
let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let back = vectorcraft_format::load(&bytes).unwrap();
assert_eq!(back.graphic_styles, s.doc().unwrap().doc.graphic_styles);
let legacy: vectorcraft_doc::GraphicStyle = serde_json::from_value(json!({"name": "Old", "appearance": style.appearance})).unwrap();
assert!(!legacy.unit_box);
s.edit("Add", |d, _| {
d.graphic_styles.push(legacy);
Ok(())
})
.unwrap();
s.execute("graphicStyle.apply", &json!({"name": "Old"})).unwrap();
assert!(close(fill_geom(&s, dst).unwrap().start, Point::new(0.2, 0.5)), "legacy styles are applied as stored");
}
#[test]
fn eyedropper_options_choose_what_copy_from_takes() {
let mut s = session();
let src = rect(&mut s, 10.0, 10.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("transparency.set", &json!({"opacity": 40})).unwrap();
let dst = rect(&mut s, 200.0, 10.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap();
assert_eq!(s.execute("eyedropper.setOptions", &json!({})).unwrap(), json!({"appearance": true, "transparency": true}));
assert_eq!(s.execute("eyedropper.setOptions", &json!({"appearance": false})).unwrap(), json!({"appearance": false, "transparency": true}));
let n = |s: &Session| s.doc().unwrap().doc.node(dst).unwrap().clone();
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0]})).unwrap();
assert_eq!((n(&s).opacity, n(&s).appearance.fill_paint().color().unwrap().to_hex()), (0.4, "#ffffff".into()));
// Params override the options for one call.
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "appearance": true})).unwrap();
assert_eq!(n(&s).appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
// Nothing to copy: no edit.
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "transparency": false})).unwrap(), json!({"ids": []}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
#[test]
fn the_gradient_tool_reads_the_constrain_angle_preference() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("prefs.set", &json!({"key": "constrainAngle", "value": 30})).unwrap();
s.select_tool("gradient", ViewInfo::default()).unwrap();
let ev = |kind, x, y, shift| vectorcraft_tools::PointerEvent::new(kind, x, y).with_mods(vectorcraft_tools::Mods { shift, ..Default::default() });
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
s.pointer(&ev(Down, 120.0, 180.0, false), ViewInfo::default()).unwrap();
s.pointer(&ev(Drag, 190.0, 140.0, true), ViewInfo::default()).unwrap();
s.pointer(&ev(Up, 190.0, 140.0, true), ViewInfo::default()).unwrap();
let id = s.doc().unwrap().selection.objects[0];
assert!((fill_geom(&s, id).unwrap().angle_deg() - 30.0).abs() < 1e-9);
}

View File

@ -0,0 +1,177 @@
//! Knockout groups, the knockout shape and the page group (M3.46–M3.48): commands, undo, files
//! (old bools load) and the PDF and SVG output.
use serde_json::{Value, json};
use vectorcraft_doc::Knockout;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap()
}
fn node(s: &Session, id: u64) -> vectorcraft_doc::Node {
s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone()
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// A group of two overlapping 50% rectangles, selected. Returns the group id.
fn knockout_group(s: &mut Session) -> u64 {
let a = run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100}))["id"].clone();
let b = run(s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 100, "height": 100}))["id"].clone();
run(s, "transparency.set", json!({"ids": [a, b], "opacity": 50}));
run(s, "select.set", json!({"ids": [a, b]}));
run(s, "object.group", json!({}))["id"].as_u64().unwrap()
}
#[test]
fn knockout_takes_three_states_names_and_bools() {
let mut s = session();
let g = knockout_group(&mut s);
assert_eq!(node(&s, g).knockout, Knockout::Neutral, "new groups are neutral");
for (param, want) in [
(json!("on"), Knockout::On),
(json!("Off"), Knockout::Off),
(json!("neutral"), Knockout::Neutral),
(json!(true), Knockout::On),
(json!(false), Knockout::Neutral),
] {
let before = steps(&s);
run(&mut s, "transparency.set", json!({"knockout": param}));
assert_eq!((node(&s, g).knockout, steps(&s)), (want, before + 1), "{param}");
}
run(&mut s, "transparency.set", json!({"knockout": "off"}));
let info = run(&mut s, "transparency.info", json!({}));
assert_eq!((info["knockout"].clone(), info["knockoutShape"].clone()), (json!("off"), json!(false)));
// A bad state changes nothing, also next to an opacity (which would go to an appearance item).
let before = steps(&s);
assert!(s.execute("transparency.set", &json!({"knockout": "maybe"})).is_err());
assert!(s.execute("transparency.set", &json!({"knockout": 3, "opacity": 20})).is_err());
assert!(s.execute("object.setProps", &json!({"knockout": "maybe"})).is_err());
assert_eq!((node(&s, g).knockout, node(&s, g).opacity, steps(&s)), (Knockout::Off, 1.0, before));
// Undo is one step per change.
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, g).knockout, Knockout::Neutral);
// The panel's click order.
assert_eq!([Knockout::On, Knockout::Neutral, Knockout::Off].map(Knockout::cycle), [Knockout::Neutral, Knockout::Off, Knockout::On]);
}
#[test]
fn knockout_shape_is_an_object_option() {
let mut s = session();
let g = knockout_group(&mut s);
let child = node(&s, g).children().unwrap()[1].id.0;
run(&mut s, "transparency.set", json!({"ids": [child], "knockoutShape": true}));
assert!(node(&s, child).knockout_shape && !node(&s, child).has_default_transparency());
let info = run(&mut s, "transparency.info", json!({"id": child}));
assert_eq!(info["knockoutShape"], true);
let mixed = run(&mut s, "transparency.info", json!({"ids": [child, g]}));
assert_eq!(mixed["knockoutShape"], Value::Null);
run(&mut s, "edit.undo", json!({}));
assert!(!node(&s, child).knockout_shape);
}
#[test]
fn page_group_toggles_are_undoable_document_settings() {
let mut s = session();
let doc = |s: &Session| (s.doc().unwrap().doc.page_isolate, s.doc().unwrap().doc.page_knockout);
let before = steps(&s);
assert_eq!(run(&mut s, "transparency.togglePageIsolatedBlending", json!({})), json!({"value": true}));
assert_eq!(run(&mut s, "transparency.togglePageKnockoutGroup", json!({"value": true})), json!({"value": true}));
assert_eq!((doc(&s), steps(&s)), ((true, true), before + 2));
// Setting the current value is not a change.
run(&mut s, "transparency.togglePageKnockoutGroup", json!({"value": true}));
assert_eq!(steps(&s), before + 2);
let info = run(&mut s, "transparency.info", json!({}));
assert_eq!((info["pageIsolatedBlending"].clone(), info["pageKnockoutGroup"].clone()), (json!(true), json!(true)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(doc(&s), (true, false));
run(&mut s, "transparency.togglePageIsolatedBlending", json!({}));
assert_eq!(doc(&s), (false, false));
}
#[test]
fn knockout_settings_round_trip_and_old_bools_load() {
let mut s = session();
let g = knockout_group(&mut s);
let child = node(&s, g).children().unwrap()[0].id.0;
run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "off"}));
run(&mut s, "transparency.set", json!({"ids": [child], "knockout": "on", "knockoutShape": true}));
run(&mut s, "transparency.togglePageIsolatedBlending", json!({}));
run(&mut s, "transparency.togglePageKnockoutGroup", json!({}));
let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let back = vectorcraft_format::load(&bytes).unwrap();
let (bg, bc) = (back.node(NodeId(g)).unwrap(), back.node(NodeId(child)).unwrap());
assert_eq!((bg.knockout, bc.knockout, bc.knockout_shape, back.page_isolate, back.page_knockout), (Knockout::Off, Knockout::On, true, true, true));
// Defaults are not written.
let text = String::from_utf8(vectorcraft_format::save(&session().doc().unwrap().doc, false)).unwrap();
assert!(!text.contains("knockout") && !text.contains("page_isolate"), "{text}");
// Files from before the three states stored a bool: true is On, false is Neutral.
let mut v: Value = serde_json::from_slice(&bytes).unwrap();
let mut patched = 0;
fn patch(v: &mut Value, n: &mut usize) {
match v {
Value::Object(m) => {
if let Some(k) = m.get_mut("knockout") {
*k = json!(*k == json!("on"));
*n += 1;
}
m.values_mut().for_each(|c| patch(c, n));
}
Value::Array(a) => a.iter_mut().for_each(|c| patch(c, n)),
_ => {}
}
}
patch(&mut v, &mut patched);
assert_eq!(patched, 2);
let old = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap();
assert_eq!((old.node(NodeId(g)).unwrap().knockout, old.node(NodeId(child)).unwrap().knockout), (Knockout::Neutral, Knockout::On));
}
#[test]
fn pdf_and_svg_write_knockout_groups_as_masks() {
let mut s = session();
let g = knockout_group(&mut s);
let plain = s.doc().unwrap().doc.clone();
run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "on"}));
let ko = s.doc().unwrap().doc.clone();
let pdf = |d: &Document| vectorcraft_pdf::export_with_report(d, &vectorcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap();
let r = pdf(&plain);
assert!(!String::from_utf8_lossy(&r.bytes).contains("/SMask") && r.warnings.is_empty());
let r = pdf(&ko);
let text = String::from_utf8_lossy(&r.bytes);
// The lower rectangle is drawn through a luminosity mask made of the upper one's alpha.
assert!(text.contains("/Luminosity") && text.contains("/Alpha"), "soft masks");
assert!(r.warnings.iter().any(|w| w.contains("knockout")), "{:?}", r.warnings);
let svg = |d: &Document| vectorcraft_svg::export(d, &Default::default());
assert!(!svg(&plain).contains("knockout"));
let out = svg(&ko);
assert_eq!(out.matches("mask=\"url(#knockout-").count(), 1, "{out}");
assert!(out.contains("<feColorMatrix") && out.contains("isolation"), "{out}");
// A neutral group passes its children through: still one mask per element below the top one.
run(&mut s, "select.set", json!({"ids": [g]}));
let outer = run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap();
run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "neutral"}));
run(&mut s, "transparency.set", json!({"ids": [outer], "knockout": "on"}));
assert_eq!(svg(&s.doc().unwrap().doc).matches("mask=\"url(#knockout-").count(), 1);
// The page group: an isolated group around the page content.
let mut page = (*plain).clone();
page.page_isolate = true;
assert!(svg(&page).contains("isolation"));
page.page_knockout = true;
assert_eq!(svg(&page).matches("mask=\"url(#knockout-").count(), 1, "the layer is neutral: its objects knock each other out");
assert!(String::from_utf8_lossy(&pdf(&page).bytes).contains("/Luminosity"));
}

View File

@ -180,11 +180,14 @@ fn info_reports_mixed_values_as_null() {
s.execute("transparency.set", &json!({"ids": [a.0, b.0], "blend": "Screen"})).unwrap(); s.execute("transparency.set", &json!({"ids": [a.0, b.0], "blend": "Screen"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let info = s.execute("transparency.info", &json!({})).unwrap(); let info = s.execute("transparency.info", &json!({})).unwrap();
assert_eq!(info, json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": false, "editingMask": null})); assert_eq!(
info,
json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": "neutral", "knockoutShape": false, "editingMask": null, "pageIsolatedBlending": false, "pageKnockoutGroup": false})
);
let one = s.execute("transparency.info", &json!({"id": a.0})).unwrap(); let one = s.execute("transparency.info", &json!({"id": a.0})).unwrap();
assert_eq!((one["opacity"].clone(), one["isolate"].clone()), (json!(50.0), json!(true))); assert_eq!((one["opacity"].clone(), one["isolate"].clone()), (json!(50.0), json!(true)));
let i = s.transparency_info(); let i = s.transparency_info();
assert_eq!((i.opacity, i.blend, i.knockout), (None, Some(vectorcraft_color::BlendMode::Screen), Some(false))); assert_eq!((i.opacity, i.blend, i.knockout), (None, Some(vectorcraft_color::BlendMode::Screen), Some(vectorcraft_doc::Knockout::Neutral)));
s.execute("select.none", &json!({})).unwrap(); s.execute("select.none", &json!({})).unwrap();
assert_eq!(s.execute("transparency.info", &json!({})).unwrap()["opacity"], Value::Null); assert_eq!(s.execute("transparency.info", &json!({})).unwrap()["opacity"], Value::Null);
// Many targets are gathered in one walk of the tree. // Many targets are gathered in one walk of the tree.

View File

@ -0,0 +1,164 @@
//! Outline Stroke (`object.path.outlineStroke`) and the live Outline Stroke effect produce the
//! stroke as the canvas paints it: dashes, arrowheads, width profiles, alignment, every stroke
//! item in order, with the stroke's opacity and blend mode moved to the new fill.
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData, Rect};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
/// A horizontal 100 pt line from (0, 50), black, no fill, with stroke options `opts`.
fn line(s: &mut Session, opts: Value) -> NodeId {
let r = s.execute("shape.line", &json!({"x1": 0, "y1": 50, "x2": 100, "y2": 50})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("stroke.set", &opts).unwrap();
id
}
pub(super) fn outline(s: &mut Session) -> Node {
let r = s.execute("object.path.outlineStroke", &json!({})).unwrap();
let id = NodeId(r["ids"][0].as_u64().unwrap());
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
pub(super) fn path_of(n: &Node) -> PathData {
match &n.kind {
NodeKind::Path { path, .. } => path.clone(),
NodeKind::Compound { children, .. } => PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()),
_ => panic!("not a path: {:?}", n.kind),
}
}
fn area(n: &Node) -> f64 {
vectorcraft_pathops::area(&path_of(n), FillRule::NonZero)
}
fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
fn close(a: Rect, b: Rect, tol: f64) -> bool {
near(a.x0, b.x0, tol) && near(a.y0, b.y0, tol) && near(a.x1, b.x1, tol) && near(a.y1, b.y1, tol)
}
#[test]
fn a_dashed_line_gives_one_subpath_per_dash() {
let mut s = session();
line(&mut s, json!({"weight": 4, "dash": [10, 10]}));
let n = outline(&mut s);
assert_eq!(path_of(&n).subpaths.len(), 5, "dashes at 0, 20, 40, 60 and 80");
assert!(near(area(&n), 5.0 * 10.0 * 4.0, 0.5), "{}", area(&n));
assert!(close(n.geometric_bounds().unwrap(), Rect::new(0.0, 48.0, 90.0, 52.0), 0.01));
}
#[test]
fn arrowheads_are_part_of_the_outline() {
let mut s = session();
line(&mut s, json!({"weight": 2, "endArrow": "Triangle"}));
let n = outline(&mut s);
// The 2 pt line plus an 8 × 8 triangle whose tip sits past the end point.
let b = n.geometric_bounds().unwrap();
assert!(b.x1 > 103.0 && near(b.height(), 8.0, 0.05), "{b:?}");
assert!(area(&n) > 200.0 + 20.0, "{}", area(&n));
assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape");
}
#[test]
fn a_lens_profile_outlines_to_half_the_rectangle() {
let mut s = session();
line(&mut s, json!({"weight": 10, "profile": "lens"}));
let n = outline(&mut s);
assert!(near(area(&n), 0.5 * 10.0 * 100.0, 2.0), "{}", area(&n));
}
#[test]
fn inside_alignment_keeps_the_outline_inside_the_path() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("stroke.set", &json!({"weight": 4, "align": "inside"})).unwrap();
let g = outline(&mut s);
let ring = &g.children().unwrap()[1];
assert!(close(ring.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 0.01));
assert!(near(area(ring), 100.0 * 100.0 - 92.0 * 92.0, 1.0), "{}", area(ring));
}
#[test]
fn stroke_opacity_and_blend_move_to_the_new_fill() {
let mut s = session();
line(&mut s, json!({"weight": 4}));
s.execute("appearance.setItem", &json!({"index": 1, "opacity": 50, "blend": "multiply"})).unwrap();
let n = outline(&mut s);
let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() else { panic!("{:?}", n.appearance) };
assert!(near(f.opacity as f64, 0.5, 1e-6) && f.blend == BlendMode::Multiply, "{f:?}");
assert!(n.opacity == 1.0 && n.blend == BlendMode::Normal);
}
#[test]
fn every_stroke_is_outlined_in_paint_order() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("appearance.addStroke", &json!({})).unwrap();
// Items: fill, 1 pt stroke, then the new top stroke at 10 pt red.
s.execute("appearance.setItem", &json!({"index": 2, "weight": 10, "color": "#ff0000"})).unwrap();
let g = outline(&mut s);
let ch = g.children().unwrap();
assert_eq!(ch.len(), 3, "the fill, then both outlines");
assert!(ch[0].appearance.items.iter().all(AppearanceItem::is_fill));
assert!(close(ch[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 1e-9));
assert!(close(ch[1].geometric_bounds().unwrap(), Rect::new(-0.5, -0.5, 100.5, 100.5), 0.01));
assert!(close(ch[2].geometric_bounds().unwrap(), Rect::new(-5.0, -5.0, 105.0, 105.0), 0.01));
assert_eq!(ch[2].appearance.fill().unwrap().paint.color().unwrap().to_hex(), "#ff0000");
// One undo step restores the stroked rectangle.
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.layers[0].children().unwrap()[0].appearance.items.len(), 3);
}
/// The live effect on a stroke item outlines that stroke (its own weight), not the top stroke.
#[test]
fn the_live_effect_outlines_the_stroke_it_sits_on() {
let mut s = session();
let id = line(&mut s, json!({"weight": 20}));
// Items: the line's empty fill, its 20 pt stroke, then a new 2 pt top stroke.
s.execute("appearance.addStroke", &json!({})).unwrap();
s.execute("appearance.setItem", &json!({"index": 2, "weight": 2})).unwrap();
// The lower (20 pt) stroke gets the effect.
s.execute("effect.apply", &json!({"effect": "path.outlineStroke", "item": 1})).unwrap();
let doc = &s.doc().unwrap().doc;
let n = doc.node(id).unwrap();
assert!(matches!(&n.appearance.items[1], AppearanceItem::Stroke(st) if st.width == 20.0 && st.effects.len() == 1), "{:?}", n.appearance.items);
let baked = vectorcraft_render::effects::bake_document(doc).unwrap();
let parts = baked.node(id).unwrap().children().unwrap().to_vec();
assert!(near(parts[1].geometric_bounds().unwrap().height(), 20.0, 0.01), "{:?}", parts[1].geometric_bounds());
assert!(near(parts[2].geometric_bounds().unwrap().height(), 0.0, 1e-9), "the top stroke keeps the line");
// On the object, the effect outlines the top stroke.
s.execute("effect.apply", &json!({"effect": "path.outlineStroke", "item": null})).unwrap();
let doc = &s.doc().unwrap().doc;
let n = doc.node(id).unwrap();
let ctx = vectorcraft_render::effects::GeomContext::of(n);
let g = vectorcraft_render::effects::apply_geometry_with(&n.appearance.effects, n.path_data().unwrap(), Rect::new(0.0, 50.0, 100.0, 50.0), &ctx);
assert!(close(g.bounds().unwrap(), Rect::new(0.0, 49.0, 100.0, 51.0), 0.01), "{:?}", g.bounds());
}
#[test]
fn brushed_strokes_outline_to_their_brush_art() {
let mut s = session();
line(&mut s, json!({"weight": 2}));
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
s.execute("appearance.setItem", &json!({"index": 1, "opacity": 50})).unwrap();
let n = outline(&mut s);
let doc = &s.doc().unwrap().doc;
let mut fills = 0;
n.walk(&mut |m| fills += m.appearance.items.iter().filter(|i| i.is_fill()).count());
assert!(fills > 0 && n.appearance.stroke().is_none(), "brush art: {n:?}");
assert!(near(n.opacity as f64, 0.5, 1e-6), "the stroke opacity stays on the art");
assert!(!doc.layers[0].children().unwrap().iter().any(|c| vectorcraft_brush::has_brush(c)));
}

View File

@ -0,0 +1,253 @@
//! Graphic styles: transparency, capture from groups and type, add-apply, unique names, links
//! (redefine, break link, delete, rename), Select All Unused, Sort by Name and Select > Same.
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{GraphicStyle, Knockout, Node};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// A selected 50×50 rectangle at `x` with the default appearance.
fn rect(s: &mut Session, x: f64) -> NodeId {
let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap());
run(s, "select.set", json!({"ids": [id.0]}));
id
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn style(s: &Session, name: &str) -> GraphicStyle {
s.doc().unwrap().doc.graphic_style(name).unwrap_or_else(|| panic!("no style {name}")).clone()
}
fn fill(s: &Session, id: NodeId) -> Paint {
node(s, id).appearance.fill_paint()
}
fn set_fill(s: &mut Session, id: NodeId, hex: &str) {
run(s, "paint.setFill", json!({"color": hex, "ids": [id.0]}));
}
fn linked(s: &mut Session, name: &str) -> Vec<u64> {
let l = run(s, "graphicStyle.list", json!({}));
let st = l["styles"].as_array().unwrap().iter().find(|g| g["name"] == name).unwrap_or_else(|| panic!("{name} not listed")).clone();
st["linked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
#[test]
fn styles_carry_opacity_and_blend_through_a_save() {
let mut s = session();
let a = rect(&mut s, 10.0);
run(&mut s, "transparency.set", json!({"item": null, "opacity": 50, "blend": "multiply", "knockout": true}));
run(&mut s, "graphicStyle.new", json!({"name": "Half"}));
let g = style(&s, "Half");
assert_eq!((g.opacity, g.blend, g.knockout), (0.5, BlendMode::Multiply, Knockout::On));
let b = rect(&mut s, 100.0);
run(&mut s, "graphicStyle.apply", json!({"name": "Half"}));
let nb = node(&s, b);
assert_eq!((nb.opacity, nb.blend, nb.knockout), (0.5, BlendMode::Multiply, Knockout::On));
let l = run(&mut s, "graphicStyle.list", json!({}));
let half = l["styles"].as_array().unwrap().iter().find(|g| g["name"] == "Half").unwrap();
assert_eq!((half["opacity"].as_f64(), half["blend"].as_str()), (Some(50.0), Some("Multiply")));
assert_eq!(linked(&mut s, "Half"), vec![a.0, b.0]);
assert_eq!(l["selected"], "Half");
// Native round trip: the style's transparency, its id and the objects' links survive.
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.graphic_styles, doc.graphic_styles);
assert_eq!(back.node(b).unwrap().graphic_style, Some(g.id));
// Styles and objects from files without links still load (and get an id when first linked).
let old: GraphicStyle = serde_json::from_value(json!({"name": "Old", "appearance": {"items": []}})).unwrap();
assert_eq!((old.id, old.opacity, old.blend, old.isolate), (0, 1.0, BlendMode::Normal, false));
let mut d = (*doc).clone();
d.graphic_styles.push(old);
let i = d.graphic_styles.len() - 1;
assert_eq!(d.graphic_style_id(i), d.graphic_styles.iter().map(|g| g.id).max().unwrap());
assert!(!serde_json::to_string(&GraphicStyle::new("Plain", Default::default())).unwrap().contains("opacity"));
}
#[test]
fn a_style_from_a_group_or_type_is_not_empty() {
let mut s = session();
let a = rect(&mut s, 10.0);
let b = rect(&mut s, 100.0);
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
assert!(node(&s, g).appearance.items.is_empty());
run(&mut s, "graphicStyle.new", json!({"name": "Group Look"}));
let gs = style(&s, "Group Look");
assert_eq!(gs.appearance.fill_paint(), Paint::solid(Color::from_hex("#ff0000").unwrap()));
// Both members share the look, so the group is linked.
assert_eq!(linked(&mut s, "Group Look"), vec![g.0]);
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 200, "text": "Hi"}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "graphicStyle.new", json!({"name": "Type Look"}));
assert!(!style(&s, "Type Look").appearance.fill_paint().is_none());
// Applying to a group styles its contents and links the group.
let c = rect(&mut s, 200.0);
run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0]}));
assert_eq!(node(&s, a).appearance, style(&s, "Black Outline").appearance);
assert_eq!(node(&s, g).graphic_style, Some(style(&s, "Black Outline").id));
assert_ne!(node(&s, c).appearance, style(&s, "Black Outline").appearance);
}
#[test]
fn alt_apply_adds_the_style_on_top() {
let mut s = session();
rect(&mut s, 10.0);
run(&mut s, "appearance.clear", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
run(&mut s, "effect.apply", json!({"effect": "distort.roughen"}));
run(&mut s, "graphicStyle.new", json!({"name": "Rough"}));
let rough = style(&s, "Rough");
let b = rect(&mut s, 100.0);
let before = node(&s, b).appearance;
run(&mut s, "graphicStyle.apply", json!({"name": "Rough", "add": true}));
let after = node(&s, b).appearance;
assert_eq!(after.items.len(), before.items.len() + rough.appearance.items.len());
assert_eq!(after.items[..before.items.len()], before.items[..]);
assert_eq!(after.items.last(), rough.appearance.items.last());
assert_eq!(after.effects.len(), before.effects.len() + 1);
assert_eq!(node(&s, b).graphic_style, None);
// One undo step.
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, b).appearance, before);
}
#[test]
fn style_names_are_unique() {
let mut s = session();
rect(&mut s, 10.0);
assert_eq!(run(&mut s, "graphicStyle.new", json!({"name": "X"}))["name"], "X");
assert_eq!(run(&mut s, "graphicStyle.new", json!({"name": "X"}))["name"], "X 2");
let n = s.doc().unwrap().doc.graphic_styles.len();
assert_eq!(run(&mut s, "graphicStyle.new", json!({}))["name"], format!("Graphic Style {}", n + 1));
assert!(s.execute("graphicStyle.rename", &json!({"name": "X 2", "to": "X"})).is_err());
assert!(s.execute("graphicStyle.rename", &json!({"name": "X 2", "to": " "})).is_err());
assert_eq!(run(&mut s, "graphicStyle.rename", json!({"name": "X 2", "to": "Y"}))["name"], "Y");
assert_eq!(run(&mut s, "graphicStyle.duplicate", json!({"name": "Y"}))["name"], "Y copy");
assert_ne!(style(&s, "Y copy").id, style(&s, "Y").id);
}
/// Three rectangles: `a` (the source of style "S"), `b` and `c`, all linked to "S".
fn linked_three(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let a = rect(s, 10.0);
run(s, "graphicStyle.new", json!({"name": "S"}));
let b = rect(s, 100.0);
let c = rect(s, 200.0);
run(s, "graphicStyle.apply", json!({"name": "S", "ids": [b.0, c.0]}));
assert_eq!(linked(s, "S"), vec![a.0, b.0, c.0]);
(a, b, c)
}
#[test]
fn redefine_updates_every_linked_object() {
let mut s = session();
let (a, b, c) = linked_three(&mut s);
set_fill(&mut s, a, "#0000ff");
// Editing the source broke its link, but it remembers the style Redefine replaces.
assert_eq!(linked(&mut s, "S"), vec![b.0, c.0]);
run(&mut s, "select.set", json!({"ids": [a.0]}));
assert_eq!(s.selection_graphic_style().map(|(g, linked)| (g.name.clone(), linked)), Some(("S".into(), false)));
run(&mut s, "graphicStyle.redefine", json!({}));
let blue = Paint::solid(Color::from_hex("#0000ff").unwrap());
assert_eq!((fill(&s, b), fill(&s, c)), (blue.clone(), blue));
assert_eq!(linked(&mut s, "S"), vec![a.0, b.0, c.0]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(fill(&s, b), Paint::solid(Color::WHITE));
}
#[test]
fn redefine_leaves_unlinked_and_edited_objects_alone() {
let mut s = session();
let (a, b, c) = linked_three(&mut s);
run(&mut s, "graphicStyle.breakLink", json!({"ids": [c.0]}));
assert_eq!(node(&s, c).graphic_style, None);
let d = rect(&mut s, 300.0);
run(&mut s, "graphicStyle.apply", json!({"name": "S"}));
set_fill(&mut s, d, "#00ff00");
set_fill(&mut s, a, "#0000ff");
run(&mut s, "graphicStyle.redefine", json!({"name": "S", "id": a.0}));
assert_eq!(fill(&s, b), Paint::solid(Color::from_hex("#0000ff").unwrap()));
assert_eq!(fill(&s, c), Paint::solid(Color::WHITE));
// The edited object keeps its look and is unlinked.
assert_eq!(fill(&s, d), Paint::solid(Color::from_hex("#00ff00").unwrap()));
assert_eq!(node(&s, d).graphic_style, None);
assert!(s.execute("graphicStyle.redefine", &json!({"id": c.0})).is_err());
}
#[test]
fn deleting_a_style_unlinks_and_renaming_keeps_links() {
let mut s = session();
let (_, b, c) = linked_three(&mut s);
run(&mut s, "graphicStyle.rename", json!({"name": "S", "to": "T"}));
assert_eq!(linked(&mut s, "T").len(), 3);
run(&mut s, "select.set", json!({"ids": [b.0]}));
assert_eq!(run(&mut s, "select.same.graphicStyle", json!({}))["count"], 3);
let look = node(&s, c).appearance;
run(&mut s, "graphicStyle.delete", json!({"name": "T"}));
assert_eq!((node(&s, b).graphic_style, node(&s, c).graphic_style), (None, None));
assert_eq!(node(&s, c).appearance, look);
run(&mut s, "select.set", json!({"ids": [b.0]}));
assert!(s.execute("select.same.graphicStyle", &json!({})).is_err());
}
#[test]
fn unused_styles_and_sort_by_name() {
let mut s = session();
linked_three(&mut s);
let unused = run(&mut s, "graphicStyle.unused", json!({}));
let names: Vec<&str> = unused["names"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert!(names.contains(&"Sunshine") && !names.contains(&"S"));
run(&mut s, "graphicStyle.delete", json!({"names": names}));
assert_eq!(s.doc().unwrap().doc.graphic_styles.len(), 1);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "graphicStyle.sortByName", json!({}));
let names: Vec<String> = s.doc().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect();
assert_eq!(names, ["Default Graphic Style", "Black Outline", "Heavy Ink", "S", "Sunshine"]);
}
#[test]
fn select_same_appearance_attribute() {
let mut s = session();
let a = rect(&mut s, 10.0);
run(&mut s, "effect.apply", json!({"effect": "distort.roughen"}));
let b = rect(&mut s, 100.0);
set_fill(&mut s, b, "#ff0000");
run(&mut s, "effect.apply", json!({"effect": "distort.roughen"}));
let c = rect(&mut s, 200.0);
// The shared effect.
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "select.same.appearanceAttribute", json!({}));
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]);
// The shared stroke (item 1).
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "select.same.appearanceAttribute", json!({"item": 1}));
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b, c]);
}
#[test]
fn document_info_lists_style_names() {
let mut s = session();
let (a, ..) = linked_three(&mut s);
let i = run(&mut s, "document.info", json!({}));
assert_eq!(i["graphicStyleNames"].as_array().unwrap().len(), 5);
run(&mut s, "select.set", json!({"ids": [a.0]}));
assert_eq!(run(&mut s, "document.info", json!({"selectionOnly": true}))["graphicStyleNames"], json!(["S"]));
}

View File

@ -0,0 +1,243 @@
//! Swatches panel commands: moving swatches between colour groups, the panel menu's Add Used
//! Colors, Select All Unused, Merge, Ungroup and Sort by Kind, and colours following the
//! document's colour mode.
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn doc(s: &Session) -> &vectorcraft_doc::Document {
&s.doc().unwrap().doc
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// The swatch names of colour group `group`, or of the ungrouped swatches.
fn names(s: &Session, group: Option<&str>) -> Vec<String> {
let d = doc(s);
let list = match group {
Some(g) => &d.swatch_groups.iter().find(|x| x.name == g).unwrap().swatches,
None => &d.swatches,
};
list.iter().map(|w| w.name.clone()).collect()
}
#[test]
fn move_between_groups_is_one_undo_step() {
let mut s = session();
let before = doc(&s).clone();
let undo = undo_len(&s);
// Two ungrouped colours into Brights, before its second swatch, in the order given.
run(&mut s, "swatch.move", json!({"names": ["Orange", "Red"], "group": "Brights", "to": 1}));
assert_eq!(names(&s, Some("Brights"))[..4], ["Bright Red", "Orange", "Red", "Bright Yellow"]);
assert!(!names(&s, None).contains(&"Red".to_string()));
assert_eq!(undo_len(&s), undo + 1);
// Out of a group to the front of the ungrouped colours (`to` counts the current entries).
run(&mut s, "swatch.move", json!({"name": "Bright Green", "to": 1}));
assert_eq!(names(&s, None)[..3], ["[None]", "Bright Green", "White"]);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(*doc(&s), before);
// Reordering within a list; past the end goes last.
run(&mut s, "swatch.move", json!({"name": "White", "to": 999}));
assert_eq!(names(&s, None).last().map(String::as_str), Some("White"));
}
#[test]
fn move_reorders_groups_and_refuses_bad_moves() {
let mut s = session();
run(&mut s, "swatch.move", json!({"name": "Brights", "to": 0}));
let groups: Vec<&str> = doc(&s).swatch_groups.iter().map(|g| g.name.as_str()).collect();
assert_eq!(groups, ["Brights", "Grays"]);
let undo = undo_len(&s);
for p in [
json!({"name": "Sunset", "group": "Grays"}),
json!({"name": "[None]"}),
json!({"name": "Grays", "group": "Brights"}),
json!({"names": ["Red", "Grays"]}),
json!({"name": "Red", "group": "Nope"}),
json!({}),
] {
assert!(s.execute("swatch.move", &p).is_err(), "{p}");
}
assert_eq!(undo_len(&s), undo, "failed moves leave no undo step");
assert!(doc(&s).swatch("Sunset").is_some_and(|w| matches!(w.paint, Paint::Gradient(_))));
}
fn rect_filled(s: &mut Session, fill: Value) -> NodeId {
let id = NodeId(run(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap());
let mut p = fill;
p["ids"] = json!([id.0]);
run(s, "paint.setFill", p);
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
id
}
fn fill(s: &Session, id: NodeId) -> Paint {
doc(s).node(id).unwrap().appearance.fill_paint()
}
#[test]
fn add_used_colors_skips_colours_that_have_swatches() {
let mut s = session();
rect_filled(&mut s, json!({"color": "#123456"}));
rect_filled(&mut s, json!({"color": "#ed1c24"}));
rect_filled(&mut s, json!({"swatch": "Orange"}));
let undo = undo_len(&s);
let r = run(&mut s, "swatch.addUsedColors", json!({}));
assert_eq!(r, json!({"added": ["R=18 G=52 B=86"], "linked": 0}), "Red and Orange are swatches already");
assert_eq!(undo_len(&s), undo + 1);
assert_eq!(run(&mut s, "swatch.addUsedColors", json!({}))["added"], json!([]));
// Only the selection's colours, as global swatches its paints link to.
let d = rect_filled(&mut s, json!({"color": "#abcdef"}));
rect_filled(&mut s, json!({"color": "#fedcba"}));
run(&mut s, "select.set", json!({"ids": [d.0]}));
let r = run(&mut s, "swatch.addUsedColors", json!({"selection": true, "global": true}));
assert_eq!(r, json!({"added": ["R=171 G=205 B=239"], "linked": 1}));
assert_eq!(fill(&s, d), Paint::Solid { color: vectorcraft_color::Color::from_hex("#abcdef").unwrap(), swatch: Some("R=171 G=205 B=239".into()) });
assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global);
}
#[test]
fn unused_leaves_out_linked_colours_gradients_and_patterns_in_use() {
let mut s = session();
let all = run(&mut s, "swatch.unused", json!({}));
assert!(!all["names"].as_array().unwrap().contains(&json!("[None]")));
assert!(all["names"].as_array().unwrap().contains(&json!("Sunset")));
run(&mut s, "swatch.edit", json!({"name": "Teal", "global": true}));
rect_filled(&mut s, json!({"swatch": "Teal"}));
run(&mut s, "swatch.edit", json!({"name": "Teal", "color": "#010203"}));
rect_filled(&mut s, json!({"swatch": "Sunset"}));
rect_filled(&mut s, json!({"color": "#ed1c24"}));
let r = rect_filled(&mut s, json!({"color": "#ff0000"}));
run(&mut s, "select.set", json!({"ids": [r.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20}));
run(&mut s, "object.pattern.done", json!({}));
rect_filled(&mut s, json!({"swatch": "Dots"}));
let names = run(&mut s, "swatch.unused", json!({}))["names"].clone();
let names: Vec<&str> = names.as_array().unwrap().iter().filter_map(Value::as_str).collect();
for used in ["Teal", "Sunset", "Red", "Dots", "White"] {
assert!(!names.contains(&used), "{used} is used: {names:?}");
}
for unused in ["Orange", "Ocean", "Bright Red", "K=50"] {
assert!(names.contains(&unused), "{unused} is unused: {names:?}");
}
}
#[test]
fn merge_keeps_the_first_and_relinks_the_others_art() {
let mut s = session();
for n in ["Red", "Orange"] {
run(&mut s, "swatch.edit", json!({"name": n, "global": true}));
}
let a = rect_filled(&mut s, json!({"swatch": "Red"}));
let b = rect_filled(&mut s, json!({"swatch": "Orange"}));
let red = doc(&s).swatch("Red").unwrap().paint.color().unwrap();
let before = doc(&s).clone();
let undo = undo_len(&s);
let r = run(&mut s, "swatch.merge", json!({"names": ["Red", "Orange"]}));
assert_eq!(r, json!({"name": "Red", "merged": ["Orange"], "relinked": 1}));
assert!(doc(&s).swatch("Orange").is_none());
assert_eq!(fill(&s, b), Paint::Solid { color: red, swatch: Some("Red".into()) });
assert_eq!(undo_len(&s), undo + 1);
run(&mut s, "edit.undo", json!({}));
assert_eq!(*doc(&s), before);
// A kept swatch that isn't global leaves the merged swatches' art unlinked, in its colour.
run(&mut s, "swatch.merge", json!({"names": ["Lime", "Red", "Bright Red"]}));
let lime = doc(&s).swatch("Lime").unwrap().paint.color().unwrap();
assert_eq!(fill(&s, a), Paint::solid(lime));
assert!(doc(&s).swatch("Red").is_none() && doc(&s).swatch("Bright Red").is_none());
for bad in [json!({"names": ["Lime"]}), json!({"names": ["Lime", "Sunset"]}), json!({"names": ["Lime", "Nope"]})] {
assert!(s.execute("swatch.merge", &bad).is_err(), "{bad}");
}
}
#[test]
fn ungroup_and_sort_by_kind_are_one_undo_step_each() {
let mut s = session();
let undo = undo_len(&s);
assert_eq!(run(&mut s, "swatch.ungroup", json!({"name": "Brights"}))["swatches"].as_array().unwrap().len(), 5);
assert!(doc(&s).swatch_groups.iter().all(|g| g.name != "Brights"));
assert_eq!(names(&s, None).last().map(String::as_str), Some("Bright Violet"));
assert!(s.execute("swatch.ungroup", &json!({"name": "Red"})).is_err());
// A gradient first, a spot colour among the process ones.
run(&mut s, "swatch.move", json!({"name": "Ocean", "to": 0}));
run(&mut s, "swatch.edit", json!({"name": "Orange", "spot": true}));
run(&mut s, "swatch.sortByKind", json!({}));
assert_eq!(undo_len(&s), undo + 4);
let rank = |w: &vectorcraft_color::Swatch| match &w.paint {
Paint::None => 0,
Paint::Solid { .. } if !w.spot => 1,
Paint::Solid { .. } => 2,
Paint::Gradient(_) => 3,
Paint::Pattern { .. } => 4,
};
let ranks: Vec<u8> = doc(&s).swatches.iter().map(rank).collect();
assert!(ranks.is_sorted(), "{ranks:?}");
let n = names(&s, None);
assert_eq!((n[0].as_str(), n[1].as_str()), ("[None]", "White"), "the order within a kind is kept");
assert_eq!(n[n.iter().position(|x| x == "Orange").unwrap() + 1], "Ocean", "the spot colour comes after the process ones, then the gradients");
}
#[test]
fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() {
use vectorcraft_color::Color;
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"}));
assert_eq!(doc(&s).color_mode, vectorcraft_doc::ColorMode::Cmyk);
assert!(doc(&s).swatches_iter().filter_map(|w| w.paint.color()).all(|c| !matches!(c, Color::Rgb { .. })));
assert_eq!(doc(&s).swatch("Black").and_then(|w| w.paint.color()), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)));
let a = rect_filled(&mut s, json!({"color": "#ff0000"}));
assert_eq!(fill(&s, a), Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)));
// Gray stays Gray; keepModel keeps RGB; gradient stops follow the document too.
run(&mut s, "paint.setFill", json!({"ids": [a.0], "color": {"gray": 0.4}}));
assert_eq!(fill(&s, a), Paint::solid(Color::gray(0.4)));
run(&mut s, "paint.setFill", json!({"ids": [a.0], "color": "#ff0000", "keepModel": true}));
assert_eq!(fill(&s, a), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
run(
&mut s,
"paint.setFill",
json!({"ids": [a.0], "gradient": {"stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#0000ff"}]}}),
);
let Paint::Gradient(g) = fill(&s, a) else { panic!("a gradient") };
assert!(g.gradient.stops.iter().all(|st| matches!(st.color, Color::Cmyk { .. })));
// A swatch made from a hex colour is CMYK as well; the document round-trips natively.
let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].clone();
assert_eq!(name, "C=100 M=0 Y=100 K=0");
let json = serde_json::to_string(doc(&s)).unwrap();
assert_eq!(serde_json::from_str::<vectorcraft_doc::Document>(&json).unwrap(), *doc(&s));
}
#[test]
fn rgb_documents_keep_colours_as_given_and_edits_keep_the_source_model() {
use vectorcraft_color::Color;
let mut s = session();
assert_eq!(doc(&s).swatch("Black").and_then(|w| w.paint.color()), Some(Color::rgb(0.0, 0.0, 0.0)));
let a = rect_filled(&mut s, json!({"color": "#ff0000"}));
assert_eq!(fill(&s, a), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
let b = rect_filled(&mut s, json!({"color": {"c": 0.0, "m": 1.0, "y": 0.0, "k": 0.0}}));
assert_eq!(fill(&s, b), Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), "an RGB document doesn't convert CMYK either");
// Recolor Artwork keeps the replaced colour's model.
run(&mut s, "select.set", json!({"ids": [b.0]}));
let hex = Color::cmyk(0.0, 1.0, 0.0, 0.0).to_hex();
run(&mut s, "recolor.apply", json!({"map": {hex: "#00ff00"}}));
assert!(matches!(fill(&s, b).color(), Some(Color::Cmyk { .. })));
// Blending ends of different models gives the document's model.
let c = rect_filled(&mut s, json!({"color": "#808080"}));
run(&mut s, "select.set", json!({"ids": [a.0, c.0, b.0]}));
run(&mut s, "edit.colors.blendFrontToBack", json!({}));
assert!(matches!(fill(&s, c).color(), Some(Color::Rgb { .. })));
}

View File

@ -81,6 +81,7 @@ impl Session {
fill_active: self.fill_active, fill_active: self.fill_active,
gradient_stop: self.selected_stop(), gradient_stop: self.selected_stop(),
appearance_item: self.appearance_item(), appearance_item: self.appearance_item(),
constrain_angle: self.prefs.constrain_angle,
}; };
f(self.tool.as_mut(), &cx) f(self.tool.as_mut(), &cx)
} }

View File

@ -42,12 +42,18 @@ pub fn normalize_deg(mut a: f64) -> f64 {
/// Snap a vector to the nearest multiple of `step_deg` (used for Shift-constrained drags). /// Snap a vector to the nearest multiple of `step_deg` (used for Shift-constrained drags).
pub fn constrain_angle(v: Vec2, step_deg: f64) -> Vec2 { pub fn constrain_angle(v: Vec2, step_deg: f64) -> Vec2 {
constrain_angle_from(v, step_deg, 0.0)
}
/// [`constrain_angle`] in steps counted from `base_deg` (the Constrain Angle preference,
/// counter-clockwise as seen on the y-down page).
pub fn constrain_angle_from(v: Vec2, step_deg: f64, base_deg: f64) -> Vec2 {
let len = v.hypot(); let len = v.hypot();
if len < EPS { if len < EPS {
return v; return v;
} }
let step = step_deg.to_radians(); let (step, base) = (step_deg.to_radians(), -base_deg.to_radians());
let a = (v.y.atan2(v.x) / step).round() * step; let a = base + ((v.y.atan2(v.x) - base) / step).round() * step;
Vec2::new(a.cos() * len, a.sin() * len) Vec2::new(a.cos() * len, a.sin() * len)
} }
@ -83,6 +89,15 @@ mod tests {
assert!((v.x - v.y).abs() < 1e-9); assert!((v.x - v.y).abs() < 1e-9);
} }
#[test]
fn constrain_from_a_base_angle() {
// 30° base: 45° steps land on 30°, 75°, … (counter-clockwise on the page, y down).
let deg = |v: Vec2| (-v.y).atan2(v.x).to_degrees();
assert!((deg(constrain_angle_from(Vec2::new(10.0, -4.0), 45.0, 30.0)) - 30.0).abs() < 1e-9);
assert!((deg(constrain_angle_from(Vec2::new(3.0, -10.0), 45.0, 30.0)) - 75.0).abs() < 1e-9);
assert!((deg(constrain_angle_from(Vec2::new(10.0, 1.0), 45.0, 30.0)) + 15.0).abs() < 1e-9);
}
#[test] #[test]
fn reference_points() { fn reference_points() {
let r = Rect::new(0.0, 0.0, 10.0, 20.0); let r = Rect::new(0.0, 0.0, 10.0, 20.0);

View File

@ -16,7 +16,7 @@ pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize
pub use edit::{ pub use edit::{
AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_redundant_points, simplify, simplify_with, smooth, split_into_grid, AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_redundant_points, simplify, simplify_with, smooth, split_into_grid,
}; };
pub use offset::{Cap, Join, offset_path, outline_stroke}; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
/// Errors from fallible operations. /// Errors from fallible operations.

View File

@ -46,11 +46,31 @@ pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_l
if width <= 0.0 || !width.is_finite() || path.is_empty() { if width <= 0.0 || !width.is_finite() || path.is_empty() {
return PathData::default(); return PathData::default();
} }
let s = stroke_bez(&path.to_bezpath(), width, cap, join, miter_limit); stroke_region(&[stroke_bez(&path.to_bezpath(), width, cap, join, miter_limit)])
if s.elements().is_empty() { }
/// The area covered by stroke outlines (each filled with the non-zero rule, open subpaths
/// closed) as one clean path: overlaps removed and the stroker's pieces refitted.
pub fn stroke_region(outlines: &[BezPath]) -> PathData {
let tidy = Tidy::free(DEFAULT_PRECISION);
let parts: Vec<BezPath> = outlines.iter().map(close_all).filter(|o| !o.elements().is_empty()).collect();
if let [one] = &parts[..] {
return normalize_bez(one, FillRule::NonZero).map(|c| all_contours_to_path(&c, &tidy)).unwrap_or_default();
}
// Each outline is normalised on its own first: a hollow arrowhead may wind against the line
// it overlaps, and the raw windings would cancel there under the non-zero rule.
let mut all = BezPath::new();
for p in &parts {
if let Ok(c) = normalize_bez(p, FillRule::NonZero) {
for ct in c.contours() {
all.extend(ct.path.iter());
}
}
}
if all.elements().is_empty() {
return PathData::default(); return PathData::default();
} }
normalize_bez(&s, FillRule::NonZero).map(|c| all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION))).unwrap_or_default() normalize_bez(&all, FillRule::NonZero).map(|c| all_contours_to_path(&c, &tidy)).unwrap_or_default()
} }
/// Object → Path → Offset Path. Positive `delta` grows the filled area, negative insets it. /// Object → Path → Offset Path. Positive `delta` grows the filled area, negative insets it.
@ -154,3 +174,22 @@ fn dist_to(bp: &BezPath, p: kurbo::Point) -> f64 {
use kurbo::ParamCurveNearest; use kurbo::ParamCurveNearest;
bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt() bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt()
} }
#[cfg(test)]
mod tests {
use kurbo::Shape;
use super::*;
#[test]
fn stroke_region_unites_outlines_whatever_their_winding() {
// A bar, and a square ring wound the other way that the bar runs into.
let bar = kurbo::Rect::new(0.0, 0.0, 20.0, 4.0).to_path(0.1);
let mut ring = kurbo::Rect::new(10.0, -8.0, 26.0, 12.0).to_path(0.1).reverse_subpaths();
ring.extend(kurbo::Rect::new(14.0, -4.0, 22.0, 8.0).to_path(0.1).iter());
let region = stroke_region(&[bar, ring]);
let area = crate::area(&region, FillRule::NonZero);
// Ring 16·20 − 8·12 = 224, plus the bar outside it (10·4) and inside its hole (6·4).
assert!((area - (224.0 + 40.0 + 24.0)).abs() < 1e-6, "{area}");
}
}

View File

@ -12,6 +12,7 @@ workspace = true
vectorcraft-geom = { workspace = true } vectorcraft-geom = { workspace = true }
vectorcraft-doc = { workspace = true } vectorcraft-doc = { workspace = true }
vectorcraft-effects = { workspace = true } vectorcraft-effects = { workspace = true }
vectorcraft-brush = { workspace = true }
vectorcraft-color = { workspace = true } vectorcraft-color = { workspace = true }
vectorcraft-text = { workspace = true } vectorcraft-text = { workspace = true }
kurbo = { workspace = true } kurbo = { workspace = true }
@ -22,3 +23,6 @@ hayro-syntax = "0.7.2"
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
log = { workspace = true } log = { workspace = true }
smallvec = "1" smallvec = "1"
[dev-dependencies]
vectorcraft-testkit = { workspace = true }

View File

@ -15,6 +15,7 @@ use kurbo::{PathEl, Shape, Vec2};
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::appearance::stroke_paint_bounds;
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject}; use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject};
use vectorcraft_effects::stroke::{self, WrittenShape};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
use crate::{Compatibility, ExportReport, PdfError, PdfOptions}; use crate::{Compatibility, ExportReport, PdfError, PdfOptions};
@ -70,7 +71,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
} }
pdf.set_metadata(meta); pdf.set_metadata(meta);
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new() }; let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new(), brushes: None, knockout: doc.page_knockout };
for i in indices { for i in indices {
let ab = &doc.artboards[i]; let ab = &doc.artboards[i];
let r = ab.rect; let r = ab.rect;
@ -79,8 +80,15 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
let mut s = page.surface(); let mut s = page.surface();
// krilla's page space is y-down with the origin at the top-left corner, like ours. // krilla's page space is y-down with the origin at the top-left corner, like ours.
s.push_transform(&xf(Affine::translate((-r.x0, -r.y0)))); s.push_transform(&xf(Affine::translate((-r.x0, -r.y0))));
for layer in &doc.layers { // Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF
ex.node(&mut s, layer, r, false); // writer has no page group attributes).
let page_group = doc.page_isolate || doc.page_knockout;
if page_group {
s.push_isolated();
}
ex.children(&mut s, &doc.layers, r);
if page_group {
s.pop();
} }
s.pop(); s.pop();
s.finish(); s.finish();
@ -129,6 +137,10 @@ struct Exporter<'a> {
doc: &'a Document, doc: &'a Document,
warnings: Vec<String>, warnings: Vec<String>,
images: HashMap<String, Option<Image>>, images: HashMap<String, Option<Image>>,
/// The brush library, parsed when the first brushed stroke is written.
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// Whether the group being written is a knockout group (what its neutral children inherit).
knockout: bool,
} }
fn xf(a: Affine) -> Transform { fn xf(a: Affine) -> Transform {
@ -196,6 +208,18 @@ fn color(c: &Color) -> krilla::color::Color {
} }
} }
/// Fill the page (with a margin) with an opaque grey level (0 = black, 255 = white): mask backdrops.
fn cover(s: &mut Surface, page: Rect, level: u8) {
let Some(p) = to_path(&page.inflate(1.0, 1.0).to_path(0.1)) else { return };
s.set_stroke(None);
s.set_fill(Some(Fill {
paint: rgb::Color::new(level, level, level).into(),
opacity: NormalizedF32::ONE,
rule: krilla::paint::FillRule::NonZero,
}));
s.draw_path(&p);
}
fn rects_overlap(a: Rect, b: Rect) -> bool { fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
} }
@ -227,6 +251,13 @@ impl Exporter<'_> {
SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::Custom(name), alt)).into() SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::Custom(name), alt)).into()
} }
/// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect.
fn warn_raster(&mut self, effects: &[vectorcraft_doc::Effect]) {
if effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) {
self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet");
}
}
fn warn(&mut self, w: impl Into<String>) { fn warn(&mut self, w: impl Into<String>) {
let w = w.into(); let w = w.into();
if !self.warnings.contains(&w) { if !self.warnings.contains(&w) {
@ -320,34 +351,75 @@ impl Exporter<'_> {
/// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white; /// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white;
/// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)). /// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)).
fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> krilla::mask::Mask { fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> krilla::mask::Mask {
let backdrop = to_path(&page.inflate(1.0, 1.0).to_path(0.1));
let white = || Fill { paint: rgb::Color::new(255, 255, 255).into(), opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero };
let mut sb = s.stream_builder(); let mut sb = s.stream_builder();
let mut ms = sb.surface(); let mut ms = sb.surface();
// An opaque backdrop when it matters: white outside the art (no clip), or black for the // An opaque backdrop when it matters: white outside the art (no clip), or black for the
// inverting Difference pass below to turn white. // inverting Difference pass below to turn white.
if let Some(bp) = &backdrop if !m.clip || m.invert {
&& (!m.clip || m.invert) cover(&mut ms, page, if m.clip { 0 } else { 255 });
{
let c = if m.clip { rgb::Color::new(0, 0, 0) } else { rgb::Color::new(255, 255, 255) };
ms.set_stroke(None);
ms.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
ms.draw_path(bp);
} }
// Mask art is a picture of its own: it takes no part in a knockout group around the object.
let knockout = std::mem::take(&mut self.knockout);
self.node(&mut ms, &m.art, page, true); self.node(&mut ms, &m.art, page, true);
if let Some(bp) = &backdrop self.knockout = knockout;
&& m.invert if m.invert {
{
ms.push_blend_mode(krilla::blend::BlendMode::Difference); ms.push_blend_mode(krilla::blend::BlendMode::Difference);
ms.set_stroke(None); cover(&mut ms, page, 255);
ms.set_fill(Some(white()));
ms.draw_path(bp);
ms.pop(); ms.pop();
} }
ms.finish(); ms.finish();
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
} }
/// The children of a group, as the elements of a knockout group when it is one.
fn children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect) {
if !self.knockout {
for c in children {
self.node(s, c, page, false);
}
return;
}
// The PDF writer has no knockout groups: each element is drawn through a soft mask of
// where the elements above it don't paint (the same look for Normal blending). The masks
// nest, so element i sits inside the masks of elements i+1…n: open them outermost first.
self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)");
let elements: Vec<_> = Node::knockout_elements(children)
.into_iter()
.filter(|c| c.visual_bounds().is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page)))
.collect();
let Some((first, rest)) = elements.split_first() else { return };
for c in rest.iter().rev() {
let mask = self.knockout_mask(s, c, page);
s.push_mask(mask);
}
self.node(s, first, page, false);
for c in rest {
s.pop();
self.node(s, c, page, false);
}
}
/// A luminosity mask that is 1 − the knockout shape of `c`: white, then black through an alpha
/// mask of `c` (at full object opacity without its own mask, unless those define its shape).
fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect) -> krilla::mask::Mask {
let shape = if c.knockout_shape { c.clone() } else { Node { opacity: 1.0, mask: None, ..c.clone() } };
let mut sb = s.stream_builder();
let mut ms = sb.surface();
cover(&mut ms, page, 255);
let alpha = {
let mut ab = ms.stream_builder();
let mut als = ab.surface();
self.node(&mut als, &shape, page, false);
als.finish();
krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha)
};
ms.push_mask(alpha);
cover(&mut ms, page, 0);
ms.pop();
ms.finish();
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
}
fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) { fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) {
if !force && !n.visible { if !force && !n.visible {
return; return;
@ -360,10 +432,11 @@ impl Exporter<'_> {
None if !n.is_container() => return, None if !n.is_container() => return,
_ => {} _ => {}
} }
if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { self.warn_raster(&n.appearance.effects);
self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet");
}
let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. }); let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. });
// Whether this container's children knock each other out (a knockout group is isolated).
let knockout = n.knocks_out(self.knockout);
let enclosing = std::mem::replace(&mut self.knockout, knockout);
let mut pushes = 0; let mut pushes = 0;
if n.blend != BlendMode::Normal { if n.blend != BlendMode::Normal {
s.push_blend_mode(blend(n.blend)); s.push_blend_mode(blend(n.blend));
@ -372,13 +445,10 @@ impl Exporter<'_> {
if n.opacity < 1.0 { if n.opacity < 1.0 {
s.push_opacity(norm(n.opacity)); s.push_opacity(norm(n.opacity));
pushes += 1; pushes += 1;
} else if container && (n.isolate || n.blend != BlendMode::Normal) { } else if container && (n.isolate || n.blend != BlendMode::Normal || knockout) {
s.push_isolated(); s.push_isolated();
pushes += 1; pushes += 1;
} }
if n.knockout {
self.warn("knockout groups are exported as normal groups");
}
if let Some(m) = n.mask.as_deref() if let Some(m) = n.mask.as_deref()
&& !m.disabled && !m.disabled
{ {
@ -387,26 +457,20 @@ impl Exporter<'_> {
pushes += 1; pushes += 1;
} }
match &n.kind { match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page),
for c in children {
self.node(s, c, page, false);
}
}
NodeKind::Group { children, clip: true } => { NodeKind::Group { children, clip: true } => {
// The region every output clips to; nothing to clip by hides the clipped art. // The region every output clips to; nothing to clip by hides the clipped art.
if let Some((clip, rest)) = children.split_first() if let Some((clip, rest)) = children.split_first()
&& let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(&bp).map(|p| (p, r))) && let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(&bp).map(|p| (p, r)))
{ {
s.push_clip_path(&p, &rule(r)); s.push_clip_path(&p, &rule(r));
for c in rest { self.children(s, rest, page);
self.node(s, c, page, false);
}
s.pop(); s.pop();
} }
} }
NodeKind::Path { path, rule, guide, .. } => { NodeKind::Path { path, rule, guide, .. } => {
if !*guide { if !*guide {
self.shape(s, n, &path.to_bezpath(), *rule); self.shape(s, n, &path.to_bezpath(), *rule, page);
} }
} }
NodeKind::Compound { children, rule } => { NodeKind::Compound { children, rule } => {
@ -416,7 +480,7 @@ impl Exporter<'_> {
bp.extend(p.to_bezpath()); bp.extend(p.to_bezpath());
} }
} }
self.shape(s, n, &bp, *rule); self.shape(s, n, &bp, *rule, page);
} }
NodeKind::Text(t) => self.text(s, n, t), NodeKind::Text(t) => self.text(s, n, t),
NodeKind::Image(im) => self.image(s, im), NodeKind::Image(im) => self.image(s, im),
@ -435,12 +499,13 @@ impl Exporter<'_> {
} }
} }
} }
self.knockout = enclosing;
for _ in 0..pushes { for _ in 0..pushes {
s.pop(); s.pop();
} }
} }
fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule) { fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule, page: Rect) {
let Some(path) = to_path(bp) else { return }; let Some(path) = to_path(bp) else { return };
let bounds = bp.bounding_box(); let bounds = bp.bounding_box();
for item in &n.appearance.items { for item in &n.appearance.items {
@ -449,9 +514,7 @@ impl Exporter<'_> {
if !fl.visible || fl.paint.is_none() { if !fl.visible || fl.paint.is_none() {
continue; continue;
} }
if fl.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { self.warn_raster(&fl.effects);
self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet");
}
// Pattern fills: the tile instances covering the shape, clipped to it. // Pattern fills: the tile instances covering the shape, clipped to it.
let doc = self.doc; let doc = self.doc;
if let Paint::Pattern { pattern, xf } = &fl.paint if let Paint::Pattern { pattern, xf } = &fl.paint
@ -486,7 +549,8 @@ impl Exporter<'_> {
if !st.visible || st.paint.is_none() || st.width <= 0.0 { if !st.visible || st.paint.is_none() || st.width <= 0.0 {
continue; continue;
} }
self.stroke(s, bp, &path, r, st, bounds); self.warn_raster(&st.effects);
self.stroke(s, bp, &path, r, st, page);
} }
} }
} }
@ -494,43 +558,27 @@ impl Exporter<'_> {
s.set_stroke(None); s.set_stroke(None);
} }
fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, bounds: Rect) { fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect) {
if st.profile.is_some() || st.brush.is_some() { if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) {
self.warn("variable-width profiles and brushes are exported as uniform strokes"); return;
} }
let Some(paint) = self.paint(&st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0)) else { return }; let bounds = bp.bounding_box();
let closed = bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath)); let Some(paint) = self.paint(&st.paint, st.paint_bounds(bounds)) else { return };
let w = stroke::for_writer(bp, st);
let mut pushes = 0; let mut pushes = 0;
if st.blend != BlendMode::Normal { if st.blend != BlendMode::Normal {
s.push_blend_mode(blend(st.blend)); s.push_blend_mode(blend(st.blend));
pushes += 1; pushes += 1;
} }
// The line ends under its arrowheads; they overlap, so both take the opacity once, as a group. match w.side {
let pieces = vectorcraft_effects::stroke::stroke_pieces(bp, st); Some(StrokeAlign::Inside) => {
let grouped = !pieces.heads.is_empty() && st.opacity < 1.0;
if grouped {
s.push_opacity(norm(st.opacity));
pushes += 1;
}
let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) };
let trimmed = match &pieces.line {
std::borrow::Cow::Owned(line) => Some(to_path(line)),
std::borrow::Cow::Borrowed(_) => None,
};
let width = match st.align {
StrokeAlign::Center => st.width,
_ if closed => st.width * 2.0,
_ => st.width,
};
match st.align {
StrokeAlign::Inside if closed => {
s.push_clip_path(path, &rule(r)); s.push_clip_path(path, &rule(r));
pushes += 1; pushes += 1;
} }
StrokeAlign::Outside if closed => { Some(StrokeAlign::Outside) => {
// Clip to everything outside the path: a big frame plus the path, even-odd. // Clip to everything outside the path: a frame around all the stroke reaches
let big = bounds.inflate(width * 2.0 + 10.0, width * 2.0 + 10.0); // (miter spikes included) plus the path, even-odd.
let mut outside = big.to_path(0.1); let mut outside = w.reach(st, bounds).inflate(1.0, 1.0).to_path(0.1);
outside.extend(bp.iter()); outside.extend(bp.iter());
if let Some(p) = to_path(&outside) { if let Some(p) = to_path(&outside) {
s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd); s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd);
@ -539,55 +587,82 @@ impl Exporter<'_> {
} }
_ => {} _ => {}
} }
let dash = st.dash.as_ref().filter(|d| d.is_dashed()).map(|d| { match &w.shape {
let mut pat: Vec<f32> = d.pattern.iter().map(|v| *v as f32).collect(); WrittenShape::Stroke { width } => {
if pat.len() % 2 == 1 { let dash = st.dash.as_ref().filter(|d| d.is_dashed()).map(|d| {
pat.extend(pat.clone()); let mut pat: Vec<f32> = d.pattern.iter().map(|v| *v as f32).collect();
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
StrokeDash { array: pat, offset: d.offset as f32 }
});
s.set_fill(None);
s.set_stroke(Some(Stroke {
paint,
width: *width as f32,
miter_limit: st.miter_limit.max(1.0) as f32,
line_cap: match st.cap {
LineCap::Butt => krilla::paint::LineCap::Butt,
LineCap::Round => krilla::paint::LineCap::Round,
LineCap::Square => krilla::paint::LineCap::Square,
},
line_join: match st.join {
LineJoin::Miter => krilla::paint::LineJoin::Miter,
LineJoin::Round => krilla::paint::LineJoin::Round,
LineJoin::Bevel => krilla::paint::LineJoin::Bevel,
},
opacity: norm(st.opacity),
dash,
}));
s.draw_path(path);
s.set_stroke(None);
} }
StrokeDash { array: pat, offset: d.offset as f32 } WrittenShape::Fill(outlines) => {
}); // The line and its arrowheads overlap: they take the opacity once, as a group.
s.set_fill(None); let grouped = outlines.len() > 1 && st.opacity < 1.0;
s.set_stroke(Some(Stroke { if grouped {
paint: paint.clone(), s.push_opacity(norm(st.opacity));
width: width as f32, pushes += 1;
miter_limit: st.miter_limit.max(1.0) as f32, }
line_cap: match st.cap { let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) };
LineCap::Butt => krilla::paint::LineCap::Butt, s.set_stroke(None);
LineCap::Round => krilla::paint::LineCap::Round, s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero }));
LineCap::Square => krilla::paint::LineCap::Square, for p in outlines.iter().filter_map(to_path) {
},
line_join: match st.join {
LineJoin::Miter => krilla::paint::LineJoin::Miter,
LineJoin::Round => krilla::paint::LineJoin::Round,
LineJoin::Bevel => krilla::paint::LineJoin::Bevel,
},
opacity,
dash,
}));
// A line fully covered by its heads (Tip alignment) has nothing left to stroke.
if let Some(line) = trimmed.as_ref().map_or(Some(path), Option::as_ref) {
s.draw_path(line);
}
s.set_stroke(None);
// Pop the alignment clip before drawing arrowheads.
if matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside) && closed && pushes > 0 {
s.pop();
pushes -= 1;
}
if !pieces.heads.is_empty() {
s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero }));
for head in &pieces.heads {
if let Some(p) = to_path(&head.outline) {
s.draw_path(&p); s.draw_path(&p);
} }
s.set_fill(None);
} }
s.set_fill(None);
} }
for _ in 0..pushes { for _ in 0..pushes {
s.pop(); s.pop();
} }
} }
/// Paint stroke `st` of the shape `bp` with its brush art (the stroke's opacity and blend mode
/// over all of it). False when it has no known brush.
fn brush_art(&mut self, s: &mut Surface, bp: &BezPath, st: &StrokeLayer, page: Rect) -> bool {
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false };
let art = vectorcraft_brush::stroke_pieces(b, bp, st);
let mut pushes = 0;
if st.blend != BlendMode::Normal {
s.push_blend_mode(blend(st.blend));
pushes += 1;
}
if st.opacity < 1.0 {
s.push_opacity(norm(st.opacity));
pushes += 1;
}
for piece in &art {
self.node(s, piece, page, false);
}
for _ in 0..pushes {
s.pop();
}
true
}
fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) { fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let tb = t.xf.transform_rect_bbox(layout.bounds); let tb = t.xf.transform_rect_bbox(layout.bounds);

View File

@ -1,7 +1,9 @@
//! VectorCraft PDF export and import. //! VectorCraft PDF export and import.
//! //!
//! - [`export`] writes one PDF page per artboard with `krilla`: vector paths (fills, strokes with //! - [`export`] writes one PDF page per artboard with `krilla`: vector paths (fills, strokes with
//! caps/joins/miter/dashes, non-zero/even-odd), opacity and blend modes (transparency groups), //! caps/joins/miter/dashes, inside/outside alignment as clips, non-zero/even-odd; arrowheads,
//! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes
//! as their brush art), opacity and blend modes (transparency groups),
//! clip groups, linear/radial gradients (shadings), embedded images and text as outlined glyph //! clip groups, linear/radial gradients (shadings), embedded images and text as outlined glyph
//! paths. Hidden objects, guides and template layers are skipped. //! paths. Hidden objects, guides and template layers are skipped.
//! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a //! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a
@ -106,4 +108,10 @@ pub enum PdfError {
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
#[cfg(test)] #[cfg(test)]
mod tests_dashalign;
#[cfg(test)]
mod tests_fx;
#[cfg(test)]
mod tests_stroke; mod tests_stroke;
#[cfg(test)]
mod tests_strokeout;

View File

@ -0,0 +1,47 @@
//! Dashes fitted to corners are written as the canvas's filled outlines; exact dashes stay a
//! PDF dash array.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, Node, NodeId, StrokeLayer};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
use crate::tests_stroke::{line_doc, painted_boxes};
use crate::*;
fn dashed(align_corners: bool) -> StrokeLayer {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
st.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners });
st
}
#[test]
fn fitted_dashes_are_written_as_fills_and_exact_ones_as_a_dash_array() {
// 60 pt: three periods with half a dash at each end, so the dashes reach both end points.
let (fills, strokes) = painted_boxes(&line_doc(dashed(true)).0);
assert!(strokes.is_empty(), "{strokes:?}");
assert_eq!(fills.len(), 1);
let b = fills[0];
assert!((b.x0 - 20.0).abs() < 0.01 && (b.x1 - 80.0).abs() < 0.01 && (b.height() - 4.0).abs() < 0.01, "{b:?}");
let (d, _) = line_doc(dashed(false));
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
let mut dashes = vec![];
back.walk(|m| dashes.extend(m.appearance.stroke().and_then(|s| s.dash.clone())));
assert_eq!(dashes.len(), 1);
assert_eq!(dashes[0].pattern, vec![10.0, 10.0]);
}
#[test]
fn fitted_dashes_on_a_rectangle_look_as_on_the_canvas() {
let mut d = Document::new(140.0, 90.0);
let mut n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)),
Appearance { items: vec![AppearanceItem::Stroke(dashed(true))], effects: vec![] },
);
n.id = d.alloc_id();
let layer = d.default_layer().unwrap();
d.insert(Some(layer), 0, n).unwrap();
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
assert_similar(&render_artboard(&d), &render_artboard(&back), 24.0, 0.002);
}

View File

@ -0,0 +1,29 @@
//! Raster effects in PDF export: not written yet, so a fill's, a stroke's or the object's own
//! raster effect is reported.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId};
use vectorcraft_geom::{Rect, shapes};
use crate::*;
fn warnings(item: Option<usize>) -> Vec<String> {
let mut d = Document::new(100.0, 100.0);
let mut n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)),
Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0),
);
n.appearance.effects_mut(item).unwrap().push(Effect { id: "stylize.outerGlow".into(), params: Default::default(), visible: true });
n.id = d.alloc_id();
let layer = d.default_layer().unwrap();
d.insert(Some(layer), 0, n).unwrap();
export_with_report(&d, &PdfOptions::default()).unwrap().warnings
}
#[test]
fn raster_effects_warn_on_fills_strokes_and_objects() {
for item in [None, Some(0), Some(1)] {
assert!(warnings(item).iter().any(|w| w.starts_with("raster effects")), "{item:?}");
}
}

View File

@ -1,14 +1,14 @@
//! Stroke geometry in PDF export: arrowheads and the trimmed line come from the shared //! Stroke geometry in PDF export: arrowheads and the trimmed line are written as the shared
//! `vectorcraft_effects::stroke` geometry, and they take the stroke opacity once. //! `vectorcraft_effects::stroke` outlines, and they take the stroke opacity once.
use vectorcraft_color::{Color, Paint}; use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_effects::stroke::stroke_pieces; use vectorcraft_effects::stroke::{OUTLINE_TOL, line_outline, stroke_pieces};
use vectorcraft_geom::{Point, Rect, Shape, shapes}; use vectorcraft_geom::{Point, Rect, Shape, shapes};
use crate::*; use crate::*;
fn line_doc(st: StrokeLayer) -> (Document, Node) { pub(crate) fn line_doc(st: StrokeLayer) -> (Document, Node) {
let mut d = Document::new(100.0, 100.0); let mut d = Document::new(100.0, 100.0);
let mut n = Node::path( let mut n = Node::path(
NodeId(0), NodeId(0),
@ -34,7 +34,7 @@ fn close(a: Rect, b: Rect) -> bool {
} }
/// Bounding boxes of the filled and the stroked paths of a PDF read back. /// Bounding boxes of the filled and the stroked paths of a PDF read back.
fn painted_boxes(d: &Document) -> (Vec<Rect>, Vec<Rect>) { pub(crate) fn painted_boxes(d: &Document) -> (Vec<Rect>, Vec<Rect>) {
let back = import(&export(d, &PdfOptions::default()).unwrap()).unwrap(); let back = import(&export(d, &PdfOptions::default()).unwrap()).unwrap();
let (mut fills, mut strokes) = (vec![], vec![]); let (mut fills, mut strokes) = (vec![], vec![]);
back.walk(|m| { back.walk(|m| {
@ -54,15 +54,17 @@ fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() {
for kind in Arrowhead::ALL { for kind in Arrowhead::ALL {
let (d, n) = line_doc(arrow_stroke(kind, align, 1.0)); let (d, n) = line_doc(arrow_stroke(kind, align, 1.0));
let bp = n.path_data().unwrap().to_bezpath(); let bp = n.path_data().unwrap().to_bezpath();
let pieces = stroke_pieces(&bp, n.appearance.stroke().unwrap()); let st = n.appearance.stroke().unwrap();
let pieces = stroke_pieces(&bp, st);
let (fills, strokes) = painted_boxes(&d); let (fills, strokes) = painted_boxes(&d);
let head = pieces.heads[0].outline.bounding_box(); let head = pieces.heads[0].outline.bounding_box();
assert_eq!(fills.len(), 1, "{kind:?} {align:?}: one head"); let line = line_outline(&pieces.line, st, st.width, OUTLINE_TOL).bounding_box();
assert!(close(fills[0], head), "{kind:?} {align:?}: {:?} vs {head:?}", fills[0]); assert!(strokes.is_empty(), "{kind:?} {align:?}: the line is written as its outline");
assert_eq!(strokes.len(), 1, "{kind:?} {align:?}: one line"); assert_eq!(fills.len(), 2, "{kind:?} {align:?}: the line, then the head");
assert!(close(strokes[0], pieces.line.bounding_box()), "{kind:?} {align:?}: {:?}", strokes[0]); assert!(close(fills[0], line), "{kind:?} {align:?}: {:?} vs {line:?}", fills[0]);
assert!(close(fills[1], head), "{kind:?} {align:?}: {:?} vs {head:?}", fills[1]);
if align == ArrowAlign::Tip { if align == ArrowAlign::Tip {
assert!((strokes[0].x1 - (80.0 - pieces.heads[0].inset)).abs() < 0.01, "{kind:?}: the stroke stops under the head"); assert!((fills[0].x1 - (80.0 - pieces.heads[0].inset)).abs() < 0.01, "{kind:?}: the stroke stops under the head");
} }
} }
} }

View File

@ -0,0 +1,71 @@
//! Strokes a PDF stroke can't draw — width profiles, brushes — are written as the canvas's filled
//! outlines; aligned strokes are clipped like on the canvas, open paths stroke centred.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, LineJoin, Node, NodeId, StrokeAlign, StrokeLayer, WidthProfile};
use vectorcraft_geom::{PathData, Point, SubPath};
use vectorcraft_testkit::raster::render_artboard;
use crate::tests_stroke::{line_doc, painted_boxes};
use crate::*;
fn black(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
st
}
#[test]
fn a_lens_profile_is_written_as_a_fill() {
let (d, _) = line_doc(black(10.0, |s| s.profile = Some(WidthProfile::lens())));
let (fills, strokes) = painted_boxes(&d);
assert!(strokes.is_empty(), "{strokes:?}");
assert_eq!(fills.len(), 1);
// The lens is full width (10 pt) only in the middle of the 20..80 line.
assert!((fills[0].height() - 10.0).abs() < 0.05 && (fills[0].width() - 60.0).abs() < 0.05, "{:?}", fills[0]);
}
#[test]
fn an_open_path_with_inside_alignment_strokes_centred() {
let (d, _) = line_doc(black(4.0, |s| s.align = StrokeAlign::Inside));
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
let mut widths = vec![];
back.walk(|m| {
assert!(!matches!(m.kind, vectorcraft_doc::NodeKind::Group { clip: true, .. }), "no clip");
widths.extend(m.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }));
});
assert_eq!(widths.len(), 1);
assert!((widths[0] - 4.0).abs() < 1e-3, "{widths:?}");
}
#[test]
fn brushed_strokes_are_written_as_their_art() {
let (d, _) = line_doc(black(2.0, |s| s.brush = Some("Tapered Stroke".into())));
let (fills, strokes) = painted_boxes(&d);
assert!(strokes.is_empty() && !fills.is_empty(), "{fills:?} {strokes:?}");
}
/// The clip frame of an outside stroke covers its miter spikes: a thin spike far above a sharp
/// apex is as dark in the exported PDF as on the canvas.
#[test]
fn outside_strokes_keep_their_miter_spikes() {
let mut d = Document::new(300.0, 300.0);
let tri = SubPath::polyline(&[Point::new(130.0, 250.0), Point::new(170.0, 250.0), Point::new(150.0, 100.0)], true);
let st = black(10.0, |s| {
s.align = StrokeAlign::Outside;
s.join = LineJoin::Miter;
s.miter_limit = 10.0;
});
let mut n = Node::path(NodeId(0), PathData::single(tri), Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] });
n.id = d.alloc_id();
let layer = d.default_layer().unwrap();
d.insert(Some(layer), 0, n).unwrap();
// The spike's tip is ~76 pt above the apex (10 pt / sin 7.6°); 60 pt up it is still 4 pt wide.
let (x, y) = (150, 40);
let canvas = render_artboard(&d);
let back = render_artboard(&import(&export(&d, &PdfOptions::default()).unwrap()).unwrap());
let dark = |p: [u8; 3]| p.iter().all(|c| *c < 64);
assert!(dark(canvas.over_white(x, y)), "the canvas draws the spike: {:?}", canvas.over_white(x, y));
assert!(dark(back.over_white(x, y)), "the PDF keeps the spike: {:?}", back.over_white(x, y));
assert!(!dark(back.over_white(x, 20)), "past its tip");
}

View File

@ -12,10 +12,16 @@
//! - Feather: like Gaussian Blur but clipped to the shape, so the edge fades inward; //! - Feather: like Gaussian Blur but clipped to the shape, so the edge fades inward;
//! - Inner Glow: a blurred inverse silhouette (Edge) or the blurred silhouette (Center), clipped //! - Inner Glow: a blurred inverse silhouette (Edge) or the blurred silhouette (Center), clipped
//! to the shape and painted above it. //! to the shape and painted above it.
//!
//! The same applies to one fill or stroke's own raster effects (around that item alone) and to
//! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is
//! drawn offscreen in the filter layers, reshaped first by their geometry effects.
use std::sync::Arc;
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind};
use vectorcraft_effects::{self as effects, GeomContext, RasterFx}; use vectorcraft_effects::{self as effects, GeomContext, RasterFx};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape, Vec2};
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_cpu::RenderContext; use vello_cpu::RenderContext;
use vello_cpu::peniko; use vello_cpu::peniko;
@ -38,6 +44,30 @@ pub(crate) fn has_fx(n: &Node) -> bool {
}) })
} }
/// Does `n` carry visible object-level effects that only apply through its art (type, images,
/// symbol instances, live objects; see [`Renderer::draw_object_fx`])?
pub(crate) fn has_object_fx(n: &Node) -> bool {
effects::needs_outline(n) && visible(&n.appearance.effects)
}
/// What [`Renderer::raster_fx`] paints effects around.
struct Content<'a> {
/// The object the effects belong to (cached shadows are checked against it).
node: &'a Node,
/// First shadow-cache slot of these effects (`None`: don't cache).
cache: Option<usize>,
/// Document-space bounds of the painted content.
reach: Rect,
/// Its silhouette: Feather and Inner Glow clip to it (without one, Feather only blurs and
/// Inner Glow is skipped).
outline: Option<(&'a BezPath, FillRule)>,
}
/// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset).
fn shifted<'a>(f: &Frame<'a>, dx: f64, dy: f64) -> Frame<'a> {
Frame { view: f.view * Affine::translate((dx, dy)), visible: f.visible - Vec2::new(dx, dy), ..*f }
}
fn node_bezpath(n: &Node) -> Option<BezPath> { fn node_bezpath(n: &Node) -> Option<BezPath> {
match &n.kind { match &n.kind {
NodeKind::Path { path, .. } => Some(path.to_bezpath()), NodeKind::Path { path, .. } => Some(path.to_bezpath()),
@ -54,11 +84,6 @@ fn node_bezpath(n: &Node) -> Option<BezPath> {
} }
} }
fn geom_ctx(n: &Node) -> GeomContext {
let w = n.appearance.stroke_width();
GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } }
}
fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath { fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath {
if !effects::has_geometry(effects) || bp.elements().is_empty() { if !effects::has_geometry(effects) || bp.elements().is_empty() {
return bp.clone(); return bp.clone();
@ -68,7 +93,7 @@ fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath {
/// The object-level effected geometry of `n` (base path `bp`). /// The object-level effected geometry of `n` (base path `bp`).
pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath { pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath {
apply(&n.appearance.effects, bp, &geom_ctx(n)) apply(&n.appearance.effects, bp, &GeomContext::of(n))
} }
fn item_effects(item: &AppearanceItem) -> &[Effect] { fn item_effects(item: &AppearanceItem) -> &[Effect] {
@ -81,13 +106,13 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] {
/// Visual bounds including geometry effects, stroke outsets and shadows/glows. /// Visual bounds including geometry effects, stroke outsets and shadows/glows.
pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> { pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
let bp = node_bezpath(n)?; let bp = node_bezpath(n)?;
let ctx = geom_ctx(n); let ctx = GeomContext::of(n);
let g = effected_path(n, &bp); let g = effected_path(n, &bp);
let mut r: Option<Rect> = (!g.elements().is_empty()).then(|| g.bounding_box()); let mut r: Option<Rect> = (!g.elements().is_empty()).then(|| g.bounding_box());
for item in &n.appearance.items { for item in &n.appearance.items {
let fx = item_effects(item); let fx = item_effects(item);
if effects::has_geometry(fx) { if effects::has_geometry(fx) {
let ig = apply(fx, &g, &ctx); let ig = apply(fx, &g, &ctx.item(item));
if !ig.elements().is_empty() { if !ig.elements().is_empty() {
let b = ig.bounding_box(); let b = ig.bounding_box();
r = Some(r.map_or(b, |r| r.union(b))); r = Some(r.map_or(b, |r| r.union(b)));
@ -106,6 +131,12 @@ pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c))) children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
} }
_ if has_fx(n) => visual_bounds(n), _ if has_fx(n) => visual_bounds(n),
// 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);
Some(b.inflate(o, o))
}
_ => n.visual_bounds(), _ => n.visual_bounds(),
} }
} }
@ -209,7 +240,7 @@ fn blur_alpha(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
} }
impl Renderer { impl Renderer {
/// Drop shadow / outer glow `fx` (index `i` in the object's raster effects) from a cached raster: /// 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 /// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused
/// while the object and the zoom/rotation are unchanged (pans only move it). Returns false when /// while the object and the zoom/rotation are unchanged (pans only move it). Returns false when
/// the effect isn't a shadow/glow or the raster would be too large (caller falls back). /// the effect isn't a shadow/glow or the raster would be too large (caller falls back).
@ -221,10 +252,8 @@ impl Renderer {
n: &Node, n: &Node,
i: usize, i: usize,
fx: &RasterFx, fx: &RasterFx,
g: &BezPath,
rule: FillRule,
gctx: &GeomContext,
reach: Rect, reach: Rect,
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) -> bool { ) -> bool {
let (mode, opacity, dx, dy, blur, color) = match fx { let (mode, opacity, dx, dy, blur, color) = match fx {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color), RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color),
@ -244,7 +273,7 @@ impl Renderer {
} }
None => { None => {
let spread = blur.max(0.0) * 1.5 / f.px + 2.0; let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
let reach = reach + vectorcraft_geom::Vec2::new(dx, dy); let reach = reach + Vec2::new(dx, dy);
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread); let r = f.view.transform_rect_bbox(reach).inflate(spread, spread);
let (x0, y0) = (r.x0.floor(), r.y0.floor()); let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0); let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
@ -254,8 +283,8 @@ impl Renderer {
let (w, h) = (w as u16, h as u16); let (w, h) = (w as u16, h as u16);
// Silhouette (the object's painted alpha), offset by the shadow distance. // Silhouette (the object's painted alpha), offset by the shadow distance.
let mut off = crate::single_threaded_context(w, h); let mut off = crate::single_threaded_context(w, h);
let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view * Affine::translate((dx, dy)), ..*f }; let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
self.paint_items(&mut off, &shifted, n, g, rule, gctx); paint(self, &mut off, &shifted(&crop, dx, dy));
off.flush(); off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h); let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources); off.render(&mut pm, &mut self.resources);
@ -298,38 +327,125 @@ impl Renderer {
self.hairline(ctx, f, &g, [0, 0, 0, 255]); self.hairline(ctx, f, &g, [0, 0, 0, 255]);
return; return;
} }
let gctx = geom_ctx(n); let gctx = GeomContext::of(n);
let rfx = effects::raster_effects(&n.appearance.effects); let rfx = effects::raster_effects(&n.appearance.effects);
// Document-space reach of the painted geometry (strokes, arrowheads…). // Document-space reach of the painted geometry (strokes, arrowheads…).
let outset = n.appearance.outset(); let outset = n.appearance.outset();
let reach = g.bounding_box().inflate(outset, outset); let reach = g.bounding_box().inflate(outset, outset);
// Below the object: shadows and outer glows. let content = Content { node: n, cache: Some(0), reach, outline: Some((&g, rule)) };
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, &g, rule, &gctx));
ctx.set_transform(Affine::IDENTITY);
}
/// Draw type, an image, a symbol instance or a live object with object-level effects
/// ([`has_object_fx`]) inside its transparency group: its art (reshaped by the geometry
/// effects, see [`effects::reshape`]) with the raster effects painted around it as for paths,
/// the art standing for the object's silhouette.
pub(crate) fn draw_object_fx(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, cache: bool) {
let opacity = self.opacity_of(a);
let layered = !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate);
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(a.blend)), Some(opacity), None, None);
}
let art = self.fx_art(f.doc, a, cache);
let rfx = effects::raster_effects(&a.appearance.effects);
match cull_bounds(&art) {
Some(reach) if !f.opts.outline && !rfx.is_empty() => {
// Feather and Inner Glow clip to the art's outline.
let clipped = rfx.iter().any(|x| matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. }));
let region = if clipped { self.clip_of(&art) } else { None };
// A symbol's art can change while the instance doesn't: no cached shadows.
let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0);
let content = Content { node: a, cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) };
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_node(c, fr, &art, true));
ctx.set_transform(Affine::IDENTITY);
}
_ => self.draw_node(ctx, f, &art, true),
}
if layered {
ctx.pop_layer();
}
self.stats.drawn += 1;
}
/// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`):
/// reshaped by its geometry effects, else type and images as themselves and the others as
/// their evaluated art; without the object's transparency and effects.
fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>, cache: bool) -> Arc<Node> {
let key = Arc::as_ptr(a) as usize;
if cache
&& let Some((node, art)) = self.fx_arts.get(&key)
&& Arc::ptr_eq(node, a)
{
return art.clone();
}
let symbol = match &a.kind {
NodeKind::SymbolInstance { symbol, .. } => {
doc.symbols.iter().find(|s| s.name == *symbol).map(|s| crate::brush_fx::instance_art(&s.art, a))
}
_ => None,
};
let art = match effects::reshape(a, symbol.as_ref()) {
Some(r) => r,
None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_)) => (**a).clone(),
None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])),
};
let art = Arc::new(Node {
opacity: 1.0,
blend: Default::default(),
isolate: false,
// One element: its pieces never knock each other out.
knockout: vectorcraft_doc::Knockout::Off,
mask: None,
appearance: vectorcraft_doc::Appearance { effects: vec![], ..art.appearance },
..art
});
if cache {
if self.fx_arts.len() > 1024 {
self.fx_arts.clear();
}
self.fx_arts.insert(key, (a.clone(), art.clone()));
}
art
}
/// Paint `content` with raster effects `rfx` (see the module docs): shadows and outer glows
/// below it, the content blurred or feathered, inner glows above it (clipped to its outline).
/// `paint` draws the content in the frame it is given.
fn raster_fx(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
content: &Content,
rfx: &[RasterFx],
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) {
let reach = content.reach;
// Below the content: shadows and outer glows.
for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) { for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) {
if self.draw_shadow_cached(ctx, f, n, i, fx, &g, rule, &gctx, reach) { if let Some(slot) = content.cache
&& self.draw_shadow_cached(ctx, f, content.node, slot + i, fx, reach, paint)
{
continue; continue;
} }
// The offset is applied to the geometry rather than in the filter: vello_cpu drops let (mode, opacity, dx, dy, blur, color) = match fx {
// layer content that lies entirely outside the viewport before filtering. RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color),
let (mode, opacity, dx, dy, blur, filter) = match fx { RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color),
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => {
(*mode, *opacity, *dx, *dy, *blur, shadow_filter(0.0, 0.0, *blur, pcolor(color)))
}
RasterFx::OuterGlow { mode, opacity, blur, color } => {
(*mode, *opacity, 0.0, 0.0, *blur, shadow_filter(0.0, 0.0, *blur, pcolor(color)))
}
_ => continue, _ => continue,
}; };
let mut gs = g.clone(); let filter = shadow_filter(0.0, 0.0, blur, pcolor(&color));
gs.apply_affine(Affine::translate((dx, dy))); // The offset moves the content rather than the filter: vello_cpu drops layer content
let reach = reach + vectorcraft_geom::Vec2::new(dx, dy); // that lies entirely outside the viewport before filtering.
let reach = reach + Vec2::new(dx, dy);
self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| { self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| {
c.set_transform(fr.view); c.set_transform(fr.view);
c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter)); c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter));
r.paint_items(c, fr, n, &gs, rule, &gctx); paint(r, c, &shifted(fr, dx, dy));
c.pop_layer(); c.pop_layer();
}); });
} }
// The object itself (blurred / feathered). // The content itself (blurred / feathered).
let blur: f64 = rfx let blur: f64 = rfx
.iter() .iter()
.map(|fx| match fx { .map(|fx| match fx {
@ -340,14 +456,17 @@ impl Renderer {
if blur > 0.0 { if blur > 0.0 {
self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| { self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| {
let mut layers = 0; let mut layers = 0;
for fx in &rfx { for fx in rfx {
match fx { match fx {
RasterFx::Feather { radius } if *radius > 0.0 => { RasterFx::Feather { radius } if *radius > 0.0 => {
c.set_transform(fr.view); c.set_transform(fr.view);
c.set_fill_rule(fill_rule(rule)); if let Some((g, rule)) = content.outline {
c.push_clip_layer(&g); c.set_fill_rule(fill_rule(rule));
c.push_clip_layer(g);
layers += 1;
}
c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0))); c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0)));
layers += 2; layers += 1;
} }
RasterFx::GaussianBlur { radius } if *radius > 0.0 => { RasterFx::GaussianBlur { radius } if *radius > 0.0 => {
c.set_transform(fr.view); c.set_transform(fr.view);
@ -357,33 +476,34 @@ impl Renderer {
_ => {} _ => {}
} }
} }
r.paint_items(c, fr, n, &g, rule, &gctx); paint(r, c, fr);
for _ in 0..layers { for _ in 0..layers {
c.pop_layer(); c.pop_layer();
} }
}); });
} else { } else {
self.paint_items(ctx, f, n, &g, rule, &gctx); paint(self, ctx, f);
} }
// Above the object: inner glows, clipped to the shape. // Above the content: inner glows, clipped to its outline.
for fx in &rfx { let Some((g, rule)) = content.outline else { return };
for fx in rfx {
let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue }; let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue };
let filter = shadow_filter(0.0, 0.0, *blur, pcolor(color)); let filter = shadow_filter(0.0, 0.0, *blur, pcolor(color));
self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| { self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| {
// The blend layer goes outside the clip: a clip layer is isolated, so a blend // The blend layer goes outside the clip: a clip layer is isolated, so a blend
// inside it would mix with nothing instead of the object below. // inside it would mix with nothing instead of the content below.
if let Some((m, o)) = comp { if let Some((m, o)) = comp {
c.set_transform(Affine::IDENTITY); c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(blend_mode(m)), Some(o), None, None); c.push_layer(None, Some(blend_mode(m)), Some(o), None, None);
} }
c.set_transform(fr.view); c.set_transform(fr.view);
c.set_fill_rule(fill_rule(rule)); c.set_fill_rule(fill_rule(rule));
c.push_clip_layer(&g); c.push_clip_layer(g);
c.push_layer(None, None, None, None, Some(filter)); c.push_layer(None, None, None, None, Some(filter));
c.set_paint(peniko::Color::BLACK); c.set_paint(peniko::Color::BLACK);
if *center { if *center {
c.set_fill_rule(fill_rule(rule)); c.set_fill_rule(fill_rule(rule));
c.fill_path(&g); c.fill_path(g);
} else { } else {
// Everything outside the shape, so the glow bleeds in from the edges. // Everything outside the shape, so the glow bleeds in from the edges.
let pad = blur * 2.0 + 4.0 * fr.px; let pad = blur * 2.0 + 4.0 * fr.px;
@ -399,7 +519,6 @@ impl Renderer {
} }
}); });
} }
ctx.set_transform(Affine::IDENTITY);
} }
/// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw` /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw`
@ -448,41 +567,66 @@ impl Renderer {
ctx.set_transform(Affine::IDENTITY); ctx.set_transform(Affine::IDENTITY);
} }
/// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects. /// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects and
/// painting per-item raster effects around that item alone.
fn paint_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, g: &BezPath, rule: FillRule, gctx: &GeomContext) { fn paint_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, g: &BezPath, rule: FillRule, gctx: &GeomContext) {
let bounds = g.bounding_box(); let bounds = g.bounding_box();
for item in &n.appearance.items { for (j, item) in n.appearance.items.iter().enumerate() {
let ig = apply(item_effects(item), g, gctx); if !item.visible() || item.paint().is_none() {
continue;
}
let ig = apply(item_effects(item), g, &gctx.item(item));
let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds }; let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds };
match item { let rfx = effects::raster_effects(item_effects(item));
AppearanceItem::Fill(fl) => { if rfx.is_empty() {
if !fl.visible || fl.paint.is_none() { self.paint_item(ctx, f, n, item, &ig, rule, ib);
continue; continue;
} }
let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_doc::color::BlendMode::Normal; // The item's silhouette: its fill area, or the outline of its stroke.
if layered { let outline = match item {
ctx.set_transform(Affine::IDENTITY); AppearanceItem::Fill(_) => ig.clone(),
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, ib, f.doc) {
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(&ig);
}
if layered {
ctx.pop_layer();
}
}
AppearanceItem::Stroke(st) => { AppearanceItem::Stroke(st) => {
if !st.visible || st.paint.is_none() || st.width <= 0.0 { let w = effects::stroke::aligned_width(st, effects::stroke::is_closed(&ig));
continue; effects::stroke::line_outline(&effects::stroke::stroke_pieces(&ig, st).line, st, w, f.px * 0.25)
}
// Brushed strokes keep their brush art under effects (e.g. a glowing scatter brush).
if st.brush.is_some() && self.draw_brush(ctx, f, n, &ig, st) {
continue;
}
self.draw_stroke(ctx, f, &ig, rule, st, ib);
} }
};
let outset = n.appearance.outset();
let orule = if item.is_fill() { rule } else { FillRule::NonZero };
// Each item's shadows get their own cache slots.
let content = Content { node: n, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) };
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, &ig, rule, ib));
ctx.set_transform(Affine::IDENTITY);
}
}
/// Paint one fill or stroke of `n` on its geometry `ig` (bounds `ib`).
#[allow(clippy::too_many_arguments)]
fn paint_item(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, item: &AppearanceItem, ig: &BezPath, rule: FillRule, ib: Rect) {
match item {
AppearanceItem::Fill(fl) => {
let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_doc::color::BlendMode::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, ib, f.doc) {
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(ig);
}
if layered {
ctx.pop_layer();
}
}
AppearanceItem::Stroke(st) => {
if st.width <= 0.0 {
return;
}
// Brushed strokes keep their brush art under effects (e.g. a glowing scatter brush).
if st.brush.is_some() && self.draw_brush(ctx, f, n, ig, st) {
return;
}
self.draw_stroke(ctx, f, ig, rule, st, ib);
} }
} }
} }

View File

@ -230,6 +230,13 @@ pub struct Renderer {
live: live::LiveCache, live: live::LiveCache,
/// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`). /// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`).
shadows: PtrMap<(usize, usize), fx::ShadowEntry>, shadows: PtrMap<(usize, usize), fx::ShadowEntry>,
/// 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
/// opacity and without its opacity mask (see [`Self::draw_knockout`]); 0 = none.
shape_of: usize,
/// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`).
fx_arts: PtrMap<usize, (Arc<Node>, Arc<Node>)>,
} }
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
@ -277,6 +284,9 @@ impl Renderer {
brushes: Default::default(), brushes: Default::default(),
shadows: PtrMap::default(), shadows: PtrMap::default(),
live: live::LiveCache::default(), live: live::LiveCache::default(),
knockout: false,
shape_of: 0,
fx_arts: PtrMap::default(),
} }
} }
@ -329,9 +339,25 @@ impl Renderer {
if !self.draw_pattern_edit(&mut ctx, &frame) { if !self.draw_pattern_edit(&mut ctx, &frame) {
// While editing an opacity mask its art is seen only through the mask (Illustrator). // While editing an opacity mask its art is seen only through the mask (Illustrator).
let mask_layer = doc.mask_edit.map(|m| m.layer); let mask_layer = doc.mask_edit.map(|m| m.layer);
for layer in doc.layers.iter().filter(|l| Some(l.id) != mask_layer) { let layers = doc.layers.iter().filter(|l| Some(l.id) != mask_layer);
self.draw_arc(&mut ctx, &frame, layer); // Page Isolated Blending / Page Knockout Group: the page is a group of its own.
let page_group = !opts.outline && (doc.page_isolate || doc.page_knockout);
self.knockout = page_group && doc.page_knockout;
if page_group {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, None, None, None);
} }
if self.knockout {
self.draw_knockout(&mut ctx, &frame, &layers.cloned().collect::<Vec<_>>());
} else {
for layer in layers {
self.draw_arc(&mut ctx, &frame, layer);
}
}
if page_group {
ctx.pop_layer();
}
self.knockout = false;
} }
if trim { if trim {
ctx.pop_layer(); ctx.pop_layer();
@ -389,6 +415,7 @@ impl Renderer {
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64)); ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
} }
let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() }; let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() };
self.knockout = false;
self.draw_node(&mut ctx, &frame, n, true); self.draw_node(&mut ctx, &frame, n, true);
ctx.flush(); ctx.flush();
let mut pm = Pixmap::new(w, w); let mut pm = Pixmap::new(w, w);
@ -453,30 +480,33 @@ impl Renderer {
Some(p) Some(p)
} }
fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) { /// Whether `a` is left out of this frame: hidden, a skipped template, or culled.
fn skipped(&mut self, f: &Frame, a: &Arc<Node>) -> bool {
let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. }); let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. });
if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) { if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) {
return; return true;
} }
match self.bounds_of(a) { match self.bounds_of(a) {
Some(b) => { Some(b) => {
let pad = f.px * 2.0; let pad = f.px * 2.0;
if !rects_overlap(b.inflate(pad, pad), f.visible) {
self.stats.culled += 1;
return;
}
// Level of detail: leaves smaller than a quarter pixel are invisible. // Level of detail: leaves smaller than a quarter pixel are invisible.
if !a.is_container() && b.width() < f.px * 0.25 && b.height() < f.px * 0.25 { let culled =
self.stats.culled += 1; !rects_overlap(b.inflate(pad, pad), f.visible) || (!a.is_container() && b.width() < f.px * 0.25 && b.height() < f.px * 0.25);
return; self.stats.culled += culled as usize;
} culled
} }
None if !a.is_container() => return, None => !a.is_container(),
None => {} }
}
fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
if self.skipped(f, a) {
return;
} }
if let Some(m) = a.mask.as_deref() if let Some(m) = a.mask.as_deref()
&& !m.disabled && !m.disabled
&& !f.opts.outline && !f.opts.outline
&& !self.is_shape(a)
{ {
let mask = self.opacity_mask(f, m, ctx.width(), ctx.height()); let mask = self.opacity_mask(f, m, ctx.width(), ctx.height());
ctx.set_transform(Affine::IDENTITY); ctx.set_transform(Affine::IDENTITY);
@ -491,7 +521,10 @@ impl Renderer {
/// Render an opacity mask's art offscreen and turn its luminance into a coverage mask. /// Render an opacity mask's art offscreen and turn its luminance into a coverage mask.
fn opacity_mask(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> vello_cpu::Mask { fn opacity_mask(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> vello_cpu::Mask {
let mut mctx = single_threaded_context(w, h); let mut mctx = single_threaded_context(w, h);
// Mask art is a picture of its own: it takes no part in a knockout group around the object.
let knockout = std::mem::take(&mut self.knockout);
self.draw_node(&mut mctx, &Frame { mt: false, ..*f }, &m.art, true); self.draw_node(&mut mctx, &Frame { mt: false, ..*f }, &m.art, true);
self.knockout = knockout;
mctx.flush(); mctx.flush();
let mut pm = Pixmap::new(w, h); let mut pm = Pixmap::new(w, h);
mctx.render(&mut pm, &mut self.resources); mctx.render(&mut pm, &mut self.resources);
@ -510,10 +543,10 @@ impl Renderer {
&& (!blended || single_plain_fill(a)) && (!blended || single_plain_fill(a))
&& !a.isolate && !a.isolate
&& !fx::has_fx(a) && !fx::has_fx(a)
&& (a.opacity >= 1.0 || painted_items(a) == 1) && (self.opacity_of(a) >= 1.0 || painted_items(a) == 1)
&& let Some(bp) = self.path_of(a) && let Some(bp) = self.path_of(a)
{ {
self.alpha = a.opacity.clamp(0.0, 1.0); self.alpha = self.opacity_of(a).clamp(0.0, 1.0);
self.cur = Some(a.clone()); self.cur = Some(a.clone());
if blended { if blended {
ctx.set_blend_mode(blend_mode(a.blend)); ctx.set_blend_mode(blend_mode(a.blend));
@ -527,8 +560,11 @@ impl Renderer {
self.stats.drawn += 1; self.stats.drawn += 1;
return; return;
} }
if fx::has_object_fx(a) {
return self.draw_object_fx(ctx, f, a, true);
}
if let NodeKind::Text(t) = &a.kind if let NodeKind::Text(t) = &a.kind
&& a.opacity >= 1.0 && self.opacity_of(a) >= 1.0
&& a.blend == vectorcraft_color::BlendMode::Normal && a.blend == vectorcraft_color::BlendMode::Normal
&& !fx::has_fx(a) && !fx::has_fx(a)
{ {
@ -558,16 +594,23 @@ impl Renderer {
} else if !n.is_container() { } else if !n.is_container() {
return; return;
} }
if fx::has_object_fx(n) {
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
}
let outline = f.opts.outline; let outline = f.opts.outline;
let mut layers = 0; let mut layers = 0;
let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates; let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates;
let opacity = if template_dim { n.opacity * 0.5 } else { n.opacity }; let opacity = if template_dim { self.opacity_of(n) * 0.5 } else { self.opacity_of(n) };
if !outline && (opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate) { // Whether this container's children knock each other out (a knockout group is isolated).
let knockout = !outline && n.knocks_out(self.knockout);
let enclosing = std::mem::replace(&mut self.knockout, knockout);
if !outline && (opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate || knockout) {
ctx.set_transform(Affine::IDENTITY); ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(n.blend)), Some(opacity), None, None); ctx.push_layer(None, Some(blend_mode(n.blend)), Some(opacity), None, None);
layers += 1; layers += 1;
} }
match &n.kind { match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } if knockout => self.draw_knockout(ctx, f, children),
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
for c in children { for c in children {
self.draw_arc(ctx, f, c); self.draw_arc(ctx, f, c);
@ -586,8 +629,12 @@ impl Renderer {
ctx.set_transform(f.view); ctx.set_transform(f.view);
ctx.set_fill_rule(fill_rule(region.1)); ctx.set_fill_rule(fill_rule(region.1));
ctx.push_clip_layer(&region.0); ctx.push_clip_layer(&region.0);
for c in rest { if knockout {
self.draw_arc(ctx, f, c); self.draw_knockout(ctx, f, rest);
} else {
for c in rest {
self.draw_arc(ctx, f, c);
}
} }
ctx.pop_layer(); ctx.pop_layer();
} }
@ -620,12 +667,44 @@ impl Renderer {
} }
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => self.draw_live_node(ctx, f, n), NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => self.draw_live_node(ctx, f, n),
} }
self.knockout = enclosing;
self.stats.drawn += 1; self.stats.drawn += 1;
for _ in 0..layers { for _ in 0..layers {
ctx.pop_layer(); ctx.pop_layer();
} }
} }
/// The children of a knockout group ([`Node::knockout_elements`]). Each element first erases
/// what the elements below it drew wherever it paints (its knockout shape: its coverage at full
/// object opacity without its opacity mask, or as drawn when its opacity and mask define the
/// knockout shape), then adds itself: it composites against the group's backdrop instead of
/// over the elements below it.
fn draw_knockout(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>]) {
for c in Node::knockout_elements(children) {
if self.skipped(f, c) {
continue;
}
for (compose, shape) in [(Compose::DestOut, !c.knockout_shape), (Compose::Plus, false)] {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, compose)), None, None, None);
self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 };
self.draw_arc(ctx, f, c);
self.shape_of = 0;
ctx.pop_layer();
}
}
}
/// Whether `n` is the knockout element being drawn as its shape (see [`Self::draw_knockout`]).
fn is_shape(&self, n: &Node) -> bool {
self.shape_of == n as *const Node as usize
}
/// The object opacity `n` is drawn with (full while it is drawn as its knockout shape).
pub(crate) fn opacity_of(&self, n: &Node) -> f32 {
if self.is_shape(n) { 1.0 } else { n.opacity }
}
fn hairline(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rgba: [u8; 4]) { fn hairline(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rgba: [u8; 4]) {
ctx.set_transform(Affine::IDENTITY); ctx.set_transform(Affine::IDENTITY);
let mut screen = bp.clone(); let mut screen = bp.clone();
@ -1012,3 +1091,7 @@ fn now() -> u64 {
mod tests; mod tests;
#[cfg(test)] #[cfg(test)]
mod tests_clip; mod tests_clip;
#[cfg(test)]
mod tests_knockout;
#[cfg(test)]
mod tests_objectfx;

View File

@ -117,10 +117,11 @@ impl Renderer {
pub(crate) fn draw_live(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) { pub(crate) fn draw_live(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
let stamp = self.stamp; let stamp = self.stamp;
self.live.tick(stamp); self.live.tick(stamp);
let layered = !f.opts.outline && (a.opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate); let opacity = self.opacity_of(a);
let layered = !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate);
if layered { if layered {
ctx.set_transform(Affine::IDENTITY); ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(a.blend)), Some(a.opacity), None, None); ctx.push_layer(None, Some(blend_mode(a.blend)), Some(opacity), None, None);
} }
self.draw_live_body(ctx, f, a, true); self.draw_live_body(ctx, f, a, true);
if layered { if layered {

View File

@ -0,0 +1,128 @@
//! Knockout groups, the knockout shape and the page group (Transparency panel).
use super::*;
use vectorcraft_color::{BlendMode as Blend, Color, Paint};
use vectorcraft_doc::{Appearance, Knockout, Node};
use vectorcraft_geom::shapes;
/// A 50% opaque rectangle: red at 10..60, blue at 40..90 (they overlap at 40..60).
fn half(d: &mut Document, x: f64, rgb: (f32, f32, f32)) -> Node {
let id = d.alloc_id();
let mut n = Node::path(
id,
shapes::rectangle(Rect::new(x, 10.0, x + 50.0, 90.0)),
Appearance::basic(Paint::solid(Color::rgb(rgb.0, rgb.1, rgb.2)), Paint::None, 0.0),
);
n.opacity = 0.5;
n
}
/// A document whose layer holds a group (`knockout`) of a 50% red and a 50% blue rectangle,
/// after `tweak` adjusts the group.
fn doc(knockout: Knockout, tweak: impl FnOnce(&mut Document, &mut Node)) -> Document {
let mut d = Document::new(100.0, 100.0);
let red = half(&mut d, 10.0, (1.0, 0.0, 0.0));
let blue = half(&mut d, 40.0, (0.0, 0.0, 1.0));
let id = d.alloc_id();
let mut g = Node::group(id, vec![Arc::new(red), Arc::new(blue)]);
g.knockout = knockout;
tweak(&mut d, &mut g);
d.insert(Some(d.layers[0].id), 0, g).unwrap();
d
}
fn render(d: &Document) -> Rendered {
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn close(a: [u8; 4], b: [u8; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= 3)
}
const BLUE_OVER_WHITE: [u8; 4] = [128, 128, 255, 255];
/// 50% blue over 50% red over white.
const BLUE_OVER_RED: [u8; 4] = [128, 64, 191, 255];
/// The overlap with half of the red knocked out by an opaque blue: 0.5 · (50% red over white) +
/// 0.5 · blue (for Normal blending, the same as 50% blue over the red).
const HALF_KNOCKED: [u8; 4] = [128, 64, 191, 255];
#[test]
fn knockout_group_shows_the_top_object_over_the_backdrop() {
let on = render(&doc(Knockout::On, |_, _| {}));
assert!(close(on.pixel(50, 50), BLUE_OVER_WHITE), "overlap: {:?}", on.pixel(50, 50));
assert!(close(on.pixel(20, 50), [255, 128, 128, 255]), "red alone: {:?}", on.pixel(20, 50));
for k in [Knockout::Off, Knockout::Neutral] {
let r = render(&doc(k, |_, _| {}));
assert!(close(r.pixel(50, 50), BLUE_OVER_RED), "{k:?}: {:?}", r.pixel(50, 50));
}
}
#[test]
fn opacity_defines_the_knockout_shape() {
// The blue object's 50% opacity knocks out only half of the red below it.
let r = render(&doc(Knockout::On, |_, g| {
let blue = Arc::make_mut(&mut g.children_mut().unwrap()[1]);
blue.knockout_shape = true;
}));
assert!(close(r.pixel(50, 50), HALF_KNOCKED), "{:?}", r.pixel(50, 50));
// Its opacity mask counts too when it defines the shape, and is ignored otherwise.
for shape in [false, true] {
let r = render(&doc(Knockout::On, |d, g| {
let id = d.alloc_id();
let white = Node::path(
id,
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0),
);
let blue = Arc::make_mut(&mut g.children_mut().unwrap()[1]);
blue.opacity = 1.0;
blue.knockout_shape = shape;
blue.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true)));
}));
let want = if shape { HALF_KNOCKED } else { BLUE_OVER_WHITE };
// A mid-grey mask is ~50% (luminance 0.5, not gamma-corrected): allow a little more slack.
let px = r.pixel(50, 50);
assert!(px.iter().zip(want).all(|(a, b)| (*a as i32 - b as i32).abs() <= 6), "shape {shape}: {px:?}");
}
}
#[test]
fn neutral_groups_pass_knockout_through() {
// Red and blue in a neutral group inside a knockout group knock each other out…
let nested = |inner: Knockout| {
doc(Knockout::On, |d, g| {
let children = std::mem::take(g.children_mut().unwrap());
let mut inner_group = Node::group(d.alloc_id(), children);
inner_group.knockout = inner;
*g.children_mut().unwrap() = vec![Arc::new(inner_group)];
})
};
assert!(close(render(&nested(Knockout::Neutral)).pixel(50, 50), BLUE_OVER_WHITE));
// …an Off group's do not…
assert!(close(render(&nested(Knockout::Off)).pixel(50, 50), BLUE_OVER_RED));
// …and with no knockout group around it, a neutral group knocks out nothing.
let mut plain = nested(Knockout::Neutral);
Arc::make_mut(&mut Arc::make_mut(&mut plain.layers[0]).children_mut().unwrap()[0]).knockout = Knockout::Neutral;
assert!(close(render(&plain).pixel(50, 50), BLUE_OVER_RED));
}
#[test]
fn page_knockout_and_isolated_blending() {
// Page Knockout Group: the neutral layer passes its objects through to the page.
let mut d = doc(Knockout::Neutral, |_, _| {});
d.page_knockout = true;
assert!(close(render(&d).pixel(50, 50), BLUE_OVER_WHITE), "{:?}", render(&d).pixel(50, 50));
// A layer set to Off keeps its objects from knocking each other out.
Arc::make_mut(&mut d.layers[0]).knockout = Knockout::Off;
assert!(close(render(&d).pixel(50, 50), BLUE_OVER_RED));
// Page Isolated Blending: a Difference object no longer blends with the white under the page.
let mut d = Document::new(100.0, 100.0);
let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0));
red.opacity = 1.0;
red.blend = Blend::Difference;
d.insert(Some(d.layers[0].id), 0, red).unwrap();
assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white: {:?}", render(&d).pixel(30, 50));
d.page_isolate = true;
assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "isolated: {:?}", render(&d).pixel(30, 50));
}

View File

@ -0,0 +1,152 @@
//! Effects on every object kind: raster effects on type, images and symbol instances through an
//! offscreen layer of their art, geometry effects through their outlines, and one fill or
//! stroke's own raster effects.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, Effect, ImageBlob, ImageObject, Node, NodeKind, Symbol};
use vectorcraft_geom::{Point, shapes};
use super::*;
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// A 100×100 document with `n` (its id allocated) on the first layer.
fn doc_with(mut d: Document, mut n: Node) -> Document {
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn render_threads(d: &Document, threads: u16) -> Rendered {
let mut r = Renderer::new();
r.threads = threads;
r.render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn render(d: &Document) -> Rendered {
render_threads(d, 0)
}
fn lum(p: [u8; 4]) -> u32 {
p[0] as u32 + p[1] as u32 + p[2] as u32
}
const WHITE: u32 = 765;
/// A 30×30 red image placed at (20, 20).
fn image_doc(effects: Vec<Effect>) -> Document {
let mut d = Document::new(100.0, 100.0);
let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
let mut n = Node::new(
NodeId(0),
NodeKind::Image(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None }),
);
n.appearance.effects = effects;
doc_with(d, n)
}
/// An instance at (50, 50) of a symbol whose art is a black 20×20 square centred on its origin.
fn symbol_doc(effects: Vec<Effect>) -> Document {
let mut d = Document::new(100.0, 100.0);
let art = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
d.symbols.push(Symbol { name: "Square".into(), art: Arc::new(art) });
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((50.0, 50.0)) });
n.appearance.effects = effects;
doc_with(d, n)
}
/// Black 40 pt "IIII" with its baseline at (5, 60).
fn text_doc(effects: Vec<Effect>) -> Document {
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
n.appearance.effects = effects;
doc_with(Document::new(100.0, 100.0), n)
}
/// Columns (x) holding any dark pixel.
fn inked_columns(img: &Rendered) -> Vec<u32> {
(0..100).filter(|x| (0..100).any(|y| lum(img.pixel(*x, y)) < 300)).collect()
}
#[test]
fn a_shadow_on_an_image_paints_offset_pixels() {
let plain = render(&image_doc(vec![]));
assert_eq!(lum(plain.pixel(55, 55)), WHITE);
let d = image_doc(vec![fx("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}))]);
let img = render(&d);
assert!(lum(img.pixel(55, 55)) < 100, "shadow below-right: {:?}", img.pixel(55, 55));
assert_eq!(img.pixel(35, 35), [255, 0, 0, 255], "the image stays on top");
assert_eq!(lum(img.pixel(15, 15)), WHITE);
// The multithreaded pipeline filters offscreen and matches.
let mt = render_threads(&d, 3);
let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "max channel difference {worst}");
}
#[test]
fn blur_on_a_symbol_instance_softens_its_edges() {
let plain = render(&symbol_doc(vec![]));
assert_eq!(lum(plain.pixel(38, 50)), WHITE);
assert_eq!(lum(plain.pixel(50, 50)), 0);
let img = render(&symbol_doc(vec![fx("blur.gaussian", json!({"radius": 8}))]));
let edge = lum(img.pixel(38, 50));
assert!(edge > 0 && edge < WHITE, "the blur spreads past the edge: {edge}");
assert!(lum(img.pixel(50, 50)) < 200, "the centre stays dark");
}
#[test]
fn geometry_effects_reshape_type_through_its_outlines() {
let plain = inked_columns(&render(&text_doc(vec![])));
assert!(!plain.is_empty() && plain[0] < 30, "{plain:?}");
// Transform moves the outlines as one piece.
let moved = inked_columns(&render(&text_doc(vec![fx("distort.transform", json!({"moveH": 30}))])));
assert_eq!(moved.first().copied(), plain.first().map(|x| x + 30));
// Roughen redraws the glyphs as rough outlines.
let d = text_doc(vec![fx("distort.roughen", json!({"size": 4, "relative": false, "detail": 30, "points": "corner"}))]);
let rough = render(&d);
let smooth = render(&text_doc(vec![]));
let changed = rough.pixels.chunks(4).zip(smooth.pixels.chunks(4)).filter(|(a, b)| a != b).count();
assert!(changed > 100, "{changed} pixels changed");
// The reshaped art is plain paths painted with the text's fill.
let n = &d.layers[0].children().unwrap()[0];
let art = vectorcraft_effects::reshape(n, None).unwrap();
let (mut text, mut black) = (0, 0);
art.walk(&mut |m| {
text += usize::from(matches!(m.kind, NodeKind::Text(_)));
black += usize::from(m.appearance.fill_paint() == Paint::solid(Color::BLACK));
});
assert!(text == 0 && black > 0, "{art:?}");
}
#[test]
fn a_strokes_own_glow_paints_around_that_stroke_only() {
let mut d = Document::new(100.0, 100.0);
let mut n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)),
Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0),
);
n.appearance.items[1].effects_mut().push(fx("stylize.outerGlow", json!({"color": "#00ff00", "mode": "normal", "opacity": 100, "blur": 6})));
d = doc_with(d, n);
let img = render(&d);
let g = img.pixel(27, 50);
assert!(g[1] > g[0] + 20 && g[1] > g[2] + 20, "green glow outside the stroke: {g:?}");
assert_eq!(img.pixel(50, 50), [255, 255, 255, 255], "the fill has no glow");
let mt = render_threads(&d, 3);
let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "max channel difference {worst}");
}

View File

@ -0,0 +1,78 @@
//! Pixel tests for dashes fitted to corners and for width profiles with dashes and joins, drawn
//! on the canvas from the shared stroke geometry (`vectorcraft_effects::stroke`).
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, LineJoin, StrokeLayer, WidthProfile};
use vectorcraft_geom::{PathData, Point, Rect, SubPath, shapes};
use vectorcraft_testkit::fixtures::DocBuilder;
use vectorcraft_testkit::raster::{Image, render_region};
/// Pixels per point in these tests.
const S: f64 = 4.0;
fn doc(path: PathData, width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Document {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
let mut b = DocBuilder::new(160.0, 120.0);
b.path(path, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }, |_| {});
b.build()
}
fn render(d: &Document) -> Image {
render_region(d, Rect::new(0.0, 0.0, 160.0, 120.0), S)
}
/// Is the pixel at document point (x, y) dark?
fn dark(img: &Image, x: f64, y: f64) -> bool {
img.over_white((x * S) as u32, (y * S) as u32)[0] < 128
}
fn rect_doc(align_corners: bool) -> Document {
doc(shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)), 2.0, |s| s.dash = Some(Dash { pattern: vec![12.0, 6.0], offset: 0.0, align_corners }))
}
#[test]
fn fitted_dashes_wrap_every_corner_of_a_rectangle() {
let img = render(&rect_doc(true));
// Half a dash (6 · 100/108) runs each way from every corner, then a gap of the same length.
let half = 6.0 * 100.0 / 108.0;
for (c, dirs) in [
((20.0, 20.0), [(1.0, 0.0), (0.0, 1.0)]),
((120.0, 20.0), [(-1.0, 0.0), (0.0, 1.0)]),
((120.0, 70.0), [(-1.0, 0.0), (0.0, -1.0)]),
((20.0, 70.0), [(1.0, 0.0), (0.0, -1.0)]),
] {
for (dx, dy) in dirs {
let at = |d: f64| (c.0 + dx * d, c.1 + dy * d);
let (x, y) = at(half / 2.0);
assert!(dark(&img, x, y), "dash beside corner {c:?}");
let (x, y) = at(half * 1.5);
assert!(!dark(&img, x, y), "gap after corner {c:?} along ({dx}, {dy})");
}
}
// Exact dashes keep their lengths instead (the exact golden covers how they look).
assert_ne!(render(&rect_doc(false)).rgba, img.rgba);
}
#[test]
fn dashes_of_a_lens_stroke_widen_towards_the_middle() {
let img = render(&doc(shapes::line(Point::new(30.0, 60.0), Point::new(130.0, 60.0)), 10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false });
}));
// The dash over 40..50 of the 100 pt line is nearly full width, the one over 0..10 thin.
assert!(dark(&img, 75.0, 60.0 - 3.8) && dark(&img, 75.0, 60.0 + 3.8));
assert!(dark(&img, 35.0, 60.0) && !dark(&img, 35.0, 60.0 - 1.5));
assert!(!dark(&img, 45.0, 60.0), "gap");
}
#[test]
fn a_round_join_on_a_profile_stroke_has_no_spike() {
let l = SubPath::polyline(&[Point::new(20.0, 40.0), Point::new(100.0, 40.0), Point::new(100.0, 110.0)], false);
let img = render(&doc(PathData::single(l), 20.0, |s| {
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] });
s.join = LineJoin::Round;
}));
assert!(dark(&img, 106.0, 34.0), "the round join covers the corner");
assert!(!dark(&img, 109.0, 31.0), "but not the miter's point");
}

View File

@ -13,6 +13,7 @@ vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true } vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true } vectorcraft-doc = { workspace = true }
vectorcraft-effects = { workspace = true } vectorcraft-effects = { workspace = true }
vectorcraft-brush = { workspace = true }
vectorcraft-text = { workspace = true } vectorcraft-text = { workspace = true }
kurbo = { workspace = true } kurbo = { workspace = true }
usvg = { workspace = true } usvg = { workspace = true }
@ -22,6 +23,7 @@ image = { workspace = true }
svgtypes = "0.16" svgtypes = "0.16"
[dev-dependencies] [dev-dependencies]
resvg = { version = "0.45", default-features = false }
vectorcraft-testkit = { workspace = true } vectorcraft-testkit = { workspace = true }
proptest = { workspace = true } proptest = { workspace = true }
image = { workspace = true } image = { workspace = true }

View File

@ -7,7 +7,8 @@ use vectorcraft_doc::{
AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind, TextObject, AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind, TextObject,
}; };
use vectorcraft_doc::{CharStyle, Justify}; use vectorcraft_doc::{CharStyle, Justify};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; use vectorcraft_effects::stroke::{self, Written, WrittenShape};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use crate::{ExportOptions, Styling, base64_encode, fmt_num, xml_escape}; use crate::{ExportOptions, Styling, base64_encode, fmt_num, xml_escape};
@ -44,10 +45,23 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String {
depth: 1, depth: 1,
patterns: HashMap::new(), patterns: HashMap::new(),
pattern_nest: 0, pattern_nest: 0,
brushes: None,
knockout: doc.page_knockout,
anonymous: false,
knockout_filter: None,
}; };
w.assign_name_ids(); w.assign_name_ids();
for l in &doc.layers { // Page Isolated Blending / Page Knockout Group: the page content is one isolated group.
w.node(l); let page_group = doc.page_isolate || doc.page_knockout;
if page_group {
let a = w.attrs(&vec![("isolation", "isolate".into())]);
w.line(&format!("<g{a}>"));
w.depth += 1;
}
w.children(&doc.layers);
if page_group {
w.depth -= 1;
w.line("</g>");
} }
let nl = if opts.minify { "" } else { "\n" }; let nl = if opts.minify { "" } else { "\n" };
@ -101,8 +115,26 @@ struct Writer<'a> {
/// `<pattern>` def ids by pattern name + placement. /// `<pattern>` def ids by pattern name + placement.
patterns: HashMap<String, String>, patterns: HashMap<String, String>,
pattern_nest: u32, pattern_nest: u32,
/// Whether the group being written is a knockout group (what its neutral children inherit).
knockout: bool,
/// Writing a copy of an object (a knockout mask): no object ids, so they stay unique.
anonymous: bool,
/// The filter that paints art black keeping its alpha (knockout masks), once defined.
knockout_filter: Option<String>,
/// The brush library, parsed when the first brushed stroke is written.
brushes: Option<Vec<vectorcraft_brush::Brush>>,
} }
/// How one stroke is written.
enum StrokePlan {
/// Its brush art (document space).
Brush(Vec<Node>),
Written(Written),
}
/// A region covering any artwork (mask and filter extents).
const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\"";
pub(crate) fn blend_css(b: BlendMode) -> &'static str { pub(crate) fn blend_css(b: BlendMode) -> &'static str {
match b { match b {
BlendMode::Normal => "normal", BlendMode::Normal => "normal",
@ -188,6 +220,9 @@ impl Writer<'_> {
} }
} }
fn id_attr(&self, n: &Node) -> String { fn id_attr(&self, n: &Node) -> String {
if self.anonymous {
return String::new();
}
self.names.get(&n.id).map(|id| format!(" id=\"{}\"", xml_escape(id))).unwrap_or_default() self.names.get(&n.id).map(|id| format!(" id=\"{}\"", xml_escape(id))).unwrap_or_default()
} }
fn matrix(&self, m: Affine) -> String { fn matrix(&self, m: Affine) -> String {
@ -430,8 +465,57 @@ impl Writer<'_> {
} }
} }
/// Paint a shape (`d`) with a node's appearance stack. /// How stroke `st` is written (`bp`: the shape in document space, built on first use from
fn shape(&mut self, n: &Node, d: &str, rule: FillRule, bounds: Option<Rect>) { /// `paths`): its brush art, or a plain stroke or filled outlines matching the canvas.
fn stroke_plan(&mut self, st: &StrokeLayer, bp: &mut Option<BezPath>, paths: &[&PathData]) -> StrokePlan {
if stroke::is_plain(st) {
return StrokePlan::Written(stroke::for_writer(&BezPath::new(), st));
}
let bp = bp.get_or_insert_with(|| {
let mut bp = BezPath::new();
for p in paths {
bp.extend(p.to_bezpath());
}
bp
});
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(b, bp, st)),
None => StrokePlan::Written(stroke::for_writer(bp, st)),
}
}
/// The `clip-path` (inside) or `mask` (outside) attribute that keeps an aligned stroke on its
/// side of the shape `d`; `reach` (document space) is what the stroke covers.
fn side_attr(&mut self, side: Option<StrokeAlign>, d: &str, rule: FillRule, reach: Rect) -> String {
match side {
None | Some(StrokeAlign::Center) => String::new(),
Some(StrokeAlign::Inside) => {
let cid = self.fresh_id("clip-path");
let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" };
self.def(1, &format!("<clipPath id=\"{cid}\">"));
self.def(2, &format!("<path d=\"{d}\"{r}/>"));
self.def(1, "</clipPath>");
format!(" clip-path=\"url(#{cid})\"")
}
Some(StrokeAlign::Outside) => {
let mid = self.fresh_id("mask");
let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0);
let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()));
let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
self.def(1, &format!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\">"));
self.def(2, &format!("<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"#fff\"/>"));
self.def(2, &format!("<path d=\"{d}\" fill=\"#000\"{fr}/>"));
self.def(1, "</mask>");
format!(" mask=\"url(#{mid})\"")
}
}
}
/// Paint a shape (`d`) with a node's appearance stack. `paths` is the same shape in document
/// space, for strokes that aren't plain (it may be empty when all are).
fn shape(&mut self, n: &Node, d: &str, paths: &[&PathData], rule: FillRule, bounds: Option<Rect>) {
let id = self.id_attr(n); let id = self.id_attr(n);
let items: Vec<&AppearanceItem> = n let items: Vec<&AppearanceItem> = n
.appearance .appearance
@ -442,14 +526,23 @@ impl Writer<'_> {
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0,
}) })
.collect(); .collect();
let mut bp = None;
let plans: Vec<Option<StrokePlan>> = items
.iter()
.map(|i| match i {
AppearanceItem::Stroke(s) => Some(self.stroke_plan(s, &mut bp, paths)),
AppearanceItem::Fill(_) => None,
})
.collect();
let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count(); let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count();
let strokes = items.len() - fills; let strokes = items.len() - fills;
// A stroke's paint box is the bounds grown by half its weight, as on the canvas.
let paint_bounds = |s: &StrokeLayer| bounds.map(|b| s.paint_bounds(b));
let simple = fills <= 1 let simple = fills <= 1
&& strokes <= 1 && strokes <= 1
&& items.iter().all(|i| match i { && !items.iter().any(|i| has_raster(i.effects()))
AppearanceItem::Fill(f) => f.blend == BlendMode::Normal, && items.iter().all(|i| i.blend() == BlendMode::Normal)
AppearanceItem::Stroke(s) => s.blend == BlendMode::Normal && s.align == StrokeAlign::Center, && plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None })));
});
if simple { if simple {
let mut p = Props::new(); let mut p = Props::new();
match items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) { match items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) {
@ -462,7 +555,7 @@ impl Writer<'_> {
} }
} }
if let Some(s) = items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) { if let Some(s) = items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) {
self.stroke_props(s, s.width, bounds, &mut p); self.stroke_props(s, s.width, paint_bounds(s), &mut p);
if matches!(items.first(), Some(AppearanceItem::Stroke(_))) && fills == 1 { if matches!(items.first(), Some(AppearanceItem::Stroke(_))) && fills == 1 {
p.push(("paint-order", "stroke".into())); p.push(("paint-order", "stroke".into()));
} }
@ -475,10 +568,12 @@ impl Writer<'_> {
let a = self.attrs(&Self::node_props(n)); let a = self.attrs(&Self::node_props(n));
self.line(&format!("<g{id}{a}>")); self.line(&format!("<g{id}{a}>"));
self.depth += 1; self.depth += 1;
for it in items { for (it, plan) in items.into_iter().zip(plans) {
// The item's own raster effects filter that item alone.
let filters = self.open_filters(n, it.effects());
let mut p = Props::new(); let mut p = Props::new();
match it { match (it, plan) {
AppearanceItem::Fill(f) => { (AppearanceItem::Fill(f), _) => {
self.fill_props(f, rule, bounds, &mut p); self.fill_props(f, rule, bounds, &mut p);
if f.blend != BlendMode::Normal { if f.blend != BlendMode::Normal {
p.push(("mix-blend-mode", blend_css(f.blend).into())); p.push(("mix-blend-mode", blend_css(f.blend).into()));
@ -486,45 +581,64 @@ impl Writer<'_> {
let a = self.attrs(&p); let a = self.attrs(&p);
self.line(&format!("<path d=\"{d}\"{a}/>")); self.line(&format!("<path d=\"{d}\"{a}/>"));
} }
AppearanceItem::Stroke(s) => { (AppearanceItem::Stroke(s), Some(StrokePlan::Brush(art))) => {
p.push(("fill", "none".into())); // The brush art takes the stroke's opacity and blend mode as a group.
let width = if s.align == StrokeAlign::Center { s.width } else { s.width * 2.0 }; if s.opacity < 1.0 {
self.stroke_props(s, width, bounds, &mut p); p.push(("opacity", fmt_num(s.opacity as f64, 3)));
}
if s.blend != BlendMode::Normal { if s.blend != BlendMode::Normal {
p.push(("mix-blend-mode", blend_css(s.blend).into())); p.push(("mix-blend-mode", blend_css(s.blend).into()));
} }
let rule_attr = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" };
let extra = match s.align {
StrokeAlign::Center => String::new(),
StrokeAlign::Inside => {
let cid = self.fresh_id("clip-path");
self.def(1, &format!("<clipPath id=\"{cid}\">"));
self.def(2, &format!("<path d=\"{d}\"{rule_attr}/>"));
self.def(1, "</clipPath>");
format!(" clip-path=\"url(#{cid})\"")
}
StrokeAlign::Outside => {
let mid = self.fresh_id("mask");
let b = self
.xf
.transform_rect_bbox(bounds.unwrap_or_default())
.inflate(width * s.miter_limit.max(1.0) + 1.0, width * s.miter_limit.max(1.0) + 1.0);
let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()));
let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
self.def(
1,
&format!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\">"),
);
self.def(2, &format!("<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"#fff\"/>"));
self.def(2, &format!("<path d=\"{d}\" fill=\"#000\"{fr}/>"));
self.def(1, "</mask>");
format!(" mask=\"url(#{mid})\"")
}
};
let a = self.attrs(&p); let a = self.attrs(&p);
self.line(&format!("<path d=\"{d}\"{a}{extra}/>")); self.line(&format!("<g{a}>"));
self.depth += 1;
for piece in &art {
self.node(piece);
}
self.depth -= 1;
self.line("</g>");
} }
(AppearanceItem::Stroke(s), Some(StrokePlan::Written(w))) => {
let side = self.side_attr(w.side, d, rule, w.reach(s, bounds.unwrap_or_default()));
let blend = (s.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(s.blend).to_string()));
match &w.shape {
WrittenShape::Stroke { width } => {
p.push(("fill", "none".into()));
self.stroke_props(s, *width, paint_bounds(s), &mut p);
p.extend(blend);
let a = self.attrs(&p);
self.line(&format!("<path d=\"{d}\"{a}{side}/>"));
}
WrittenShape::Fill(outlines) => {
let paint = self.paint(&s.paint, paint_bounds(s));
let ds: Vec<String> = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect();
let opacity = (s.opacity < 1.0).then(|| fmt_num(s.opacity as f64, 3));
if let [one] = &ds[..] {
p.push(("fill", paint));
p.extend(opacity.map(|o| ("fill-opacity", o)));
p.extend(blend);
let a = self.attrs(&p);
self.line(&format!("<path d=\"{one}\"{a}{side}/>"));
} else if !ds.is_empty() {
// The line and its arrowheads overlap: one group takes the opacity.
p.extend(opacity.map(|o| ("opacity", o)));
p.extend(blend);
let a = self.attrs(&p);
self.line(&format!("<g{a}{side}>"));
self.depth += 1;
let fill = self.attrs(&vec![("fill", paint)]);
for one in &ds {
self.line(&format!("<path d=\"{one}\"{fill}/>"));
}
self.depth -= 1;
self.line("</g>");
}
}
}
}
(AppearanceItem::Stroke(_), None) => {}
} }
self.close_filters(filters);
} }
self.depth -= 1; self.depth -= 1;
self.line("</g>"); self.line("</g>");
@ -533,13 +647,11 @@ impl Writer<'_> {
/// An object with an opacity mask: `<g mask="url(#…)">` around the unmasked object. The mask /// An object with an opacity mask: `<g mask="url(#…)">` around the unmasked object. The mask
/// art goes in `<defs>`; no-clip adds a white backdrop and invert a colour-inverting filter. /// art goes in `<defs>`; no-clip adds a white backdrop and invert a colour-inverting filter.
fn masked(&mut self, n: &Node, m: &vectorcraft_doc::OpacityMask) { fn masked(&mut self, n: &Node, m: &vectorcraft_doc::OpacityMask) {
const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\"";
let mid = self.fresh_id("mask"); let mid = self.fresh_id("mask");
let (body, depth) = (std::mem::take(&mut self.body), self.depth); // Mask art is a picture of its own: it takes no part in a knockout group around the object.
self.depth = 2; let knockout = std::mem::take(&mut self.knockout);
self.node(&m.art); let art = self.detached(2, |w| w.node(&m.art));
let art = std::mem::replace(&mut self.body, body); self.knockout = knockout;
self.depth = depth;
let inv = m.invert.then(|| { let inv = m.invert.then(|| {
let fid = self.fresh_id("invert"); let fid = self.fresh_id("invert");
self.def(1, &format!("<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\" {BIG}>")); self.def(1, &format!("<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\" {BIG}>"));
@ -569,11 +681,90 @@ impl Writer<'_> {
self.line("</g>"); self.line("</g>");
} }
/// What `write` writes to the body, taken out of it (written at `depth`, for `<defs>`).
fn detached(&mut self, depth: usize, write: impl FnOnce(&mut Self)) -> String {
let (body, saved) = (std::mem::take(&mut self.body), self.depth);
self.depth = depth;
write(self);
self.depth = saved;
std::mem::replace(&mut self.body, body)
}
/// Props of a group: a knockout group is isolated.
fn group_props(&self, n: &Node) -> Props {
let mut p = Self::node_props(n);
if !n.isolate && n.knocks_out(self.knockout) {
p.push(("isolation", "isolate".into()));
}
p
}
/// The children of group `n`, as the elements of a knockout group when it is one.
fn group_children(&mut self, n: &Node, children: &[std::sync::Arc<Node>]) {
let knockout = n.knocks_out(self.knockout);
let enclosing = std::mem::replace(&mut self.knockout, knockout);
self.children(children);
self.knockout = enclosing;
}
/// Children of the group being written. SVG has no knockout groups: in one, each element is
/// drawn through a mask of where the elements above it don't paint (the same look for Normal
/// blending). The masks nest, so element i sits inside the masks of elements i+1…n.
fn children(&mut self, children: &[std::sync::Arc<Node>]) {
if !self.knockout {
for c in children {
self.node(c);
}
return;
}
let elements = Node::knockout_elements(children);
let Some((first, rest)) = elements.split_first() else { return };
for c in rest.iter().rev() {
let mid = self.knockout_mask(c);
self.line(&format!("<g mask=\"url(#{mid})\">"));
self.depth += 1;
}
self.node(first);
for c in rest {
self.depth -= 1;
self.line("</g>");
self.node(c);
}
}
/// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object
/// opacity without its own mask, unless those define its shape). Returns the mask id.
fn knockout_mask(&mut self, c: &Node) -> String {
let filter = match &self.knockout_filter {
Some(f) => f.clone(),
None => {
let f = self.fresh_id("knockout-shape");
self.def(1, &format!("<filter id=\"{f}\" filterUnits=\"userSpaceOnUse\" {BIG}>"));
self.def(2, "<feColorMatrix type=\"matrix\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0\"/>");
self.def(1, "</filter>");
self.knockout_filter = Some(f.clone());
f
}
};
let shape = if c.knockout_shape { c.clone() } else { Node { opacity: 1.0, mask: None, ..c.clone() } };
let anonymous = std::mem::replace(&mut self.anonymous, true);
let art = self.detached(3, |w| w.node(&shape));
self.anonymous = anonymous;
let mid = self.fresh_id("knockout");
self.def(1, &format!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" {BIG}>"));
self.def(2, &format!("<rect {BIG} fill=\"white\"/>"));
self.def(2, &format!("<g filter=\"url(#{filter})\">"));
self.defs.push_str(&art);
self.def(2, "</g>");
self.def(1, "</mask>");
mid
}
fn node(&mut self, n: &Node) { fn node(&mut self, n: &Node) {
if !n.visible { if !n.visible {
return; return;
} }
if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { if has_raster(&n.appearance.effects) {
return self.filtered(n); return self.filtered(n);
} }
if let Some(m) = n.mask.as_deref() if let Some(m) = n.mask.as_deref()
@ -585,12 +776,10 @@ impl Writer<'_> {
NodeKind::Layer { template: true, .. } => {} NodeKind::Layer { template: true, .. } => {}
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
let id = self.id_attr(n); let id = self.id_attr(n);
let a = self.attrs(&Self::node_props(n)); let a = self.attrs(&self.group_props(n));
self.line(&format!("<g{id}{a}>")); self.line(&format!("<g{id}{a}>"));
self.depth += 1; self.depth += 1;
for c in children { self.group_children(n, children);
self.node(c);
}
self.depth -= 1; self.depth -= 1;
self.line("</g>"); self.line("</g>");
} }
@ -607,12 +796,10 @@ impl Writer<'_> {
} }
self.def(1, "</clipPath>"); self.def(1, "</clipPath>");
let id = self.id_attr(n); let id = self.id_attr(n);
let a = self.attrs(&Self::node_props(n)); let a = self.attrs(&self.group_props(n));
self.line(&format!("<g{id} clip-path=\"url(#{cid})\"{a}>")); self.line(&format!("<g{id} clip-path=\"url(#{cid})\"{a}>"));
self.depth += 1; self.depth += 1;
for c in rest { self.group_children(n, rest);
self.node(c);
}
self.depth -= 1; self.depth -= 1;
self.line("</g>"); self.line("</g>");
} }
@ -622,20 +809,15 @@ impl Writer<'_> {
return; return;
} }
let d = self.path_d(path, self.xf); let d = self.path_d(path, self.xf);
self.shape(n, &d, *rule, n.geometric_bounds()); self.shape(n, &d, &[path], *rule, n.geometric_bounds());
} }
NodeKind::Compound { children, rule } => { NodeKind::Compound { children, rule } => {
let d: Vec<String> = children let paths: Vec<&PathData> = children.iter().filter(|c| c.visible).filter_map(|c| c.path_data()).filter(|p| !p.is_empty()).collect();
.iter() if paths.is_empty() {
.filter(|c| c.visible)
.filter_map(|c| c.path_data())
.filter(|p| !p.is_empty())
.map(|p| self.path_d(p, self.xf))
.collect();
if d.is_empty() {
return; return;
} }
self.shape(n, &d.join(" "), *rule, n.geometric_bounds()); let d: Vec<String> = paths.iter().map(|p| self.path_d(p, self.xf)).collect();
self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds());
} }
NodeKind::Text(t) if self.opts.outline_text => self.text_outlines(n, t), NodeKind::Text(t) if self.opts.outline_text => self.text_outlines(n, t),
NodeKind::Text(t) => self.text(n, t), NodeKind::Text(t) => self.text(n, t),
@ -665,7 +847,10 @@ impl Writer<'_> {
let a = self.attrs(&Self::node_props(n)); let a = self.attrs(&Self::node_props(n));
self.line(&format!("<g{id}{a}>")); self.line(&format!("<g{id}{a}>"));
self.depth += 1; self.depth += 1;
// The symbol's art is the instance's own picture, outside any knockout around it.
let knockout = std::mem::take(&mut self.knockout);
self.node(&sym.art.clone()); self.node(&sym.art.clone());
self.knockout = knockout;
self.depth -= 1; self.depth -= 1;
self.line("</g>"); self.line("</g>");
self.xf = saved; self.xf = saved;
@ -679,15 +864,22 @@ impl Writer<'_> {
} }
/// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `<g filter>` /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `<g filter>`
/// per effect, the first effect innermost (Illustrator's stacking order). /// per effect, the first effect innermost (the reference app's stacking order).
fn filtered(&mut self, n: &Node) { fn filtered(&mut self, n: &Node) {
use vectorcraft_effects::RasterFx;
let fx = vectorcraft_effects::raster_effects(&n.appearance.effects);
let mut inner = n.clone(); let mut inner = n.clone();
inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id));
let opened = self.open_filters(n, &n.appearance.effects);
self.node(&inner);
self.close_filters(opened);
}
/// Open one `<g filter>` per visible raster effect of `effects` (an object's, or one fill or
/// stroke's) on `n`, outermost last effect; returns how many to close.
fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize {
use vectorcraft_effects::RasterFx;
let fx = vectorcraft_effects::raster_effects(effects);
let reach: f64 = fx.iter().map(RasterFx::outset).sum::<f64>() + 2.0; let reach: f64 = fx.iter().map(RasterFx::outset).sum::<f64>() + 2.0;
let region = n.visual_bounds().map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); let region = n.visual_bounds().map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
let mut opened = 0;
for f in fx.iter().rev() { for f in fx.iter().rev() {
let fid = self.fresh_id("filter"); let fid = self.fresh_id("filter");
let region_attr = match region { let region_attr = match region {
@ -741,9 +933,11 @@ impl Writer<'_> {
self.def(1, &format!("<filter id=\"{fid}\"{region_attr} color-interpolation-filters=\"sRGB\">{body}</filter>")); self.def(1, &format!("<filter id=\"{fid}\"{region_attr} color-interpolation-filters=\"sRGB\">{body}</filter>"));
self.line(&format!("<g filter=\"url(#{fid})\">")); self.line(&format!("<g filter=\"url(#{fid})\">"));
self.depth += 1; self.depth += 1;
opened += 1;
} }
self.node(&inner); fx.len()
}
fn close_filters(&mut self, opened: usize) {
for _ in 0..opened { for _ in 0..opened {
self.depth -= 1; self.depth -= 1;
self.line("</g>"); self.line("</g>");
@ -824,7 +1018,8 @@ impl Writer<'_> {
Node::path(NodeId(u64::MAX), pd.clone(), vectorcraft_doc::Appearance::basic(st.fill.clone(), st.stroke.clone(), st.stroke_width)); Node::path(NodeId(u64::MAX), pd.clone(), vectorcraft_doc::Appearance::basic(st.fill.clone(), st.stroke.clone(), st.stroke_width));
let d = self.path_d(&pd, Affine::IDENTITY); let d = self.path_d(&pd, Affine::IDENTITY);
let bounds = pd.bounds(); let bounds = pd.bounds();
self.shape(&glyphs, &d, FillRule::NonZero, bounds); // Run strokes are plain: they need no document-space geometry.
self.shape(&glyphs, &d, &[], FillRule::NonZero, bounds);
} }
self.depth -= 1; self.depth -= 1;
self.line("</g>"); self.line("</g>");
@ -948,3 +1143,8 @@ impl Writer<'_> {
out out
} }
} }
/// Any visible raster effect (shadow, glow, blur, feather) in `effects`?
fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id))
}

View File

@ -8,14 +8,19 @@
//! //!
//! ## Export approximations //! ## Export approximations
//! //!
//! * **Stroke alignment** is not expressible in SVG 1.1. An *inside* stroke is written as a stroke of //! * **Stroke alignment** is not expressible in SVG 1.1. An *inside* stroke of a closed path is
//! double width clipped to the path's own shape (`<clipPath>`); an *outside* stroke as a stroke of //! written as a stroke of double width clipped to the path's own shape (`<clipPath>`); an
//! double width masked by a `<mask>` that hides the path's interior. Both are visually exact for //! *outside* stroke as a stroke of double width masked by a `<mask>` that hides the path's
//! closed paths but re-import as a group (clip group / masked stroke) rather than an aligned stroke. //! interior. Both are visually exact but re-import as a group (clip group / masked stroke)
//! rather than an aligned stroke. Open paths have no inside: their strokes are written centred,
//! as the canvas draws them.
//! * **Arrowheads, width profiles, dashes fitted to corners and dotted dashes** are written as the
//! filled outlines the canvas paints (the line and each arrowhead, grouped under the stroke's
//! opacity); **brushed strokes** as their brush art. Both re-import as filled paths.
//! * **Multiple fills/strokes** (or per-fill/stroke blend modes) are written as a `<g>` holding one //! * **Multiple fills/strokes** (or per-fill/stroke blend modes) are written as a `<g>` holding one
//! `<path>` per appearance item, in paint order. //! `<path>` per appearance item, in paint order.
//! * Effects, arrowheads, width profiles, brushes, patterns and freeform gradients are not //! * Live geometry effects are written as their result and raster effects (shadows, glows, blurs)
//! expanded (patterns export as `none`, freeform gradients as linear). //! as SVG filters. Freeform gradients export as linear gradients.
//! //!
//! ## Import approximations //! ## Import approximations
//! //!
@ -34,7 +39,7 @@ mod import;
pub use vectorcraft_doc::Document; pub use vectorcraft_doc::Document;
pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::TextObject;
/// How style properties are written (Illustrator's "Styling" option). /// How style properties are written (the "Styling" export option).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Styling { pub enum Styling {
/// `fill="#ff0000"` attributes. /// `fill="#ff0000"` attributes.

View File

@ -0,0 +1,41 @@
//! Dashes fitted to corners export as the canvas's filled outlines (a dash array can't fit them);
//! exact dashes stay a `stroke-dasharray`. Checked by rendering the exported SVG with resvg.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, Node, StrokeLayer};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_svg::{ExportOptions, export};
use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard};
fn rect_doc(align_corners: bool) -> Document {
let mut d = Document::new(140.0, 90.0);
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
st.dash = Some(Dash { pattern: vec![12.0, 6.0], offset: 0.0, align_corners });
let n = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)),
Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] },
);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
/// The SVG rendered by resvg on white at 1 px/pt.
fn resvg_render(svg: &str, w: u32, h: u32) -> Image {
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse");
let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap();
pm.fill(resvg::tiny_skia::Color::WHITE);
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut());
Image { width: w, height: h, rgba: pm.data().to_vec() }
}
#[test]
fn fitted_dashes_export_as_outlines_that_render_like_the_canvas() {
let d = rect_doc(true);
let svg = export(&d, &ExportOptions::default());
assert!(!svg.contains("stroke-dasharray") && !svg.contains("stroke="), "{svg}");
assert_similar(&render_artboard(&d), &resvg_render(&svg, 140, 90), 24.0, 0.002);
// Exact dashes are still a dash array.
assert!(export(&rect_doc(false), &ExportOptions::default()).contains("stroke-dasharray=\"12 6\""));
}

View File

@ -0,0 +1,58 @@
//! Live effects in SVG export: a fill or stroke's own raster effects become filters on that item's
//! element, and geometry effects on type are baked through its outlines.
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
use vectorcraft_svg::{ExportOptions, export};
fn doc_with(mut n: Node) -> Document {
let mut d = Document::new(200.0, 200.0);
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn fx(id: &str) -> Effect {
Effect { id: id.into(), params: json!({}), visible: true }
}
#[test]
fn a_strokes_own_glow_filters_the_stroke_element_only() {
let mut n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)),
Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0),
);
n.appearance.items[1].effects_mut().push(fx("stylize.outerGlow"));
let svg = export(&doc_with(n), &ExportOptions::default());
assert_eq!(svg.matches("<filter ").count(), 1, "{svg}");
// The filter group holds the stroke's path, and the fill's path comes before it, unfiltered.
let open = svg.find("<g filter=\"url(#").expect("a filter group");
let inside = &svg[open..svg[open..].find("</g>").map(|i| open + i).unwrap()];
assert!(inside.contains("stroke=\"#000000\"") && !inside.contains("fill=\"#ffffff\""), "{inside}");
assert!(svg[..open].contains("fill=\"#ffffff\""), "{svg}");
// A hidden effect exports nothing.
let mut n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)),
Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0),
);
n.appearance.items[1].effects_mut().push(Effect { visible: false, ..fx("stylize.outerGlow") });
assert!(!export(&doc_with(n), &ExportOptions::default()).contains("<filter "));
}
#[test]
fn geometry_effects_on_type_export_as_its_reshaped_outlines() {
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "Hi", style))));
let plain = export(&doc_with(n.clone()), &ExportOptions::default());
assert!(plain.contains("<text"), "{plain}");
n.appearance.effects =
vec![Effect { id: "distort.roughen".into(), params: json!({"size": 3, "relative": false}), visible: true }, fx("stylize.dropShadow")];
let svg = export(&doc_with(n), &ExportOptions::default());
assert!(!svg.contains("<text") && svg.contains("<path"), "{svg}");
assert!(svg.contains("<filter "), "the shadow stays a filter: {svg}");
}

View File

@ -0,0 +1,81 @@
//! Strokes in SVG export look as on the canvas: arrowheads, width profiles, dots and brushes are
//! written as the canvas's filled outlines or art, aligned strokes are clipped or masked, and open
//! paths stroke centred. Checked by rendering the exported SVG with resvg.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, WidthProfile};
use vectorcraft_geom::{PathData, Point, SubPath, shapes};
use vectorcraft_svg::{ExportOptions, export};
use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard};
fn add(d: &mut Document, path: PathData, f: impl FnOnce(&mut StrokeLayer)) {
let mut st = StrokeLayer::new(Paint::solid(Color::rgb(0.1, 0.2, 0.6)), 4.0);
f(&mut st);
let n = Node::path(d.alloc_id(), path, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] });
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, n).unwrap();
}
fn line(x0: f64, y: f64, x1: f64) -> PathData {
shapes::line(Point::new(x0, y), Point::new(x1, y))
}
/// The exported SVG rendered by resvg on white at 1 px/pt.
fn resvg_render(svg: &str, w: u32, h: u32) -> Image {
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse");
let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap();
pm.fill(resvg::tiny_skia::Color::WHITE);
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut());
Image { width: w, height: h, rgba: pm.data().to_vec() }
}
#[test]
fn exported_strokes_render_like_the_canvas() {
let mut d = Document::new(200.0, 200.0);
add(&mut d, line(20.0, 20.0, 150.0), |s| {
s.end_arrow = Some(Arrowhead::Triangle);
s.start_arrow = Some(Arrowhead::CircleOpen);
s.opacity = 0.5;
});
add(&mut d, line(20.0, 50.0, 180.0), |s| {
s.width = 14.0;
s.profile = Some(WidthProfile::lens());
});
add(&mut d, line(20.0, 80.0, 180.0), |s| {
s.cap = LineCap::Square;
s.dash = Some(Dash { pattern: vec![0.0, 12.0], offset: 0.0, align_corners: false });
});
add(&mut d, line(20.0, 110.0, 180.0), |s| s.brush = Some("Tapered Stroke".into()));
let tri = SubPath::polyline(&[Point::new(60.0, 185.0), Point::new(100.0, 185.0), Point::new(80.0, 135.0)], true);
add(&mut d, PathData::single(tri), |s| {
s.align = StrokeAlign::Outside;
s.join = LineJoin::Miter;
s.width = 6.0;
});
let svg = export(&d, &ExportOptions::default());
assert!(!svg.contains("stroke-dasharray"), "dots are outlines:\n{svg}");
let canvas = render_artboard(&d);
let written = resvg_render(&svg, 200, 200);
assert_similar(&canvas, &written, 24.0, 0.002);
// The comparison is meaningful: every stroke drew something.
for y in [20, 50, 80, 110, 160] {
assert!((0..200).any(|x| canvas.over_white(x, y).iter().any(|c| *c < 200)), "row {y} has ink");
}
}
#[test]
fn an_open_path_with_inside_alignment_exports_a_plain_stroke() {
let mut d = Document::new(200.0, 100.0);
add(&mut d, line(20.0, 50.0, 180.0), |s| s.align = StrokeAlign::Inside);
let svg = export(&d, &ExportOptions::default());
assert!(svg.contains("stroke-width=\"4\""), "{svg}");
assert!(!svg.contains("clip-path") && !svg.contains("mask"), "{svg}");
}
#[test]
fn a_lens_profile_exports_a_fill() {
let mut d = Document::new(200.0, 100.0);
add(&mut d, line(20.0, 50.0, 180.0), |s| s.profile = Some(WidthProfile::lens()));
let svg = export(&d, &ExportOptions::default());
assert!(svg.contains("fill=\"#1a3399\"") && !svg.contains("stroke="), "{svg}");
}

View File

@ -163,6 +163,8 @@ pub struct ToolContext<'a> {
/// The Appearance panel's active fill/stroke (paint-order index into the first selected /// The Appearance panel's active fill/stroke (paint-order index into the first selected
/// object's stack); paint tools show and edit that item. /// object's stack); paint tools show and edit that item.
pub appearance_item: Option<usize>, pub appearance_item: Option<usize>,
/// General → Constrain Angle (degrees): Shift constrains drags to 45° steps from it.
pub constrain_angle: f64,
} }
impl ToolContext<'_> { impl ToolContext<'_> {
@ -346,6 +348,7 @@ pub(crate) mod testutil {
fill_active: true, fill_active: true,
gradient_stop: None, gradient_stop: None,
appearance_item: None, appearance_item: None,
constrain_angle: 0.0,
} }
} }
} }

View File

@ -33,7 +33,7 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
"scale" => Box::new(TransformTool::new(TransformKind::Scale)), "scale" => Box::new(TransformTool::new(TransformKind::Scale)),
"shear" => Box::new(TransformTool::new(TransformKind::Shear)), "shear" => Box::new(TransformTool::new(TransformKind::Shear)),
"freeTransform" => Box::new(FreeTransformTool::default()), "freeTransform" => Box::new(FreeTransformTool::default()),
"eyedropper" => Box::new(EyedropperTool), "eyedropper" => Box::new(EyedropperTool::default()),
"gradient" => Box::new(GradientTool::default()), "gradient" => Box::new(GradientTool::default()),
"artboard" => Box::new(ArtboardTool::default()), "artboard" => Box::new(ArtboardTool::default()),
"magicWand" => Box::new(MagicWandTool), "magicWand" => Box::new(MagicWandTool),
@ -79,7 +79,7 @@ pub(crate) fn rect_corners(r: Rect) -> [Point; 4] {
} }
/// The node that owns the appearance for a hit leaf (a compound path's child → the compound). /// The node that owns the appearance for a hit leaf (a compound path's child → the compound).
pub(crate) fn paint_owner(doc: &Document, leaf: NodeId) -> NodeId { pub fn paint_owner(doc: &Document, leaf: NodeId) -> NodeId {
match doc.parent_of(leaf) { match doc.parent_of(leaf) {
Some(p) if doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) => p, Some(p) if doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) => p,
_ => leaf, _ => leaf,

View File

@ -3,8 +3,12 @@
//! Click an object: copy its appearance (fill, stroke, weight, opacity) to the selection and the //! Click an object: copy its appearance (fill, stroke, weight, opacity) to the selection and the
//! paint defaults (`appearance.copyFrom`). Shift-click: sample only the colour under the cursor into //! paint defaults (`appearance.copyFrom`). Shift-click: sample only the colour under the cursor into
//! the active fill/stroke (`paint.sampleColor`). //! the active fill/stroke (`paint.sampleColor`).
//!
//! The Gradient panel's eyedropper sets the `stop` option (the tool to return to): the next click
//! samples the colour under the cursor into the selected gradient stop (`paint.sampleColor
//! {stop}`) and switches back to that tool.
use serde_json::json; use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint}; use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::hit::{HitKind, hit_test}; use vectorcraft_doc::hit::{HitKind, hit_test};
use vectorcraft_geom::Point; use vectorcraft_geom::Point;
@ -12,7 +16,11 @@ use vectorcraft_geom::Point;
use super::paint_owner; use super::paint_owner;
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
pub struct EyedropperTool; #[derive(Default)]
pub struct EyedropperTool {
/// Sampling for the selected gradient stop: the tool to switch back to afterwards.
stop: Option<String>,
}
/// The colour of `paint` at `p` (gradients are sampled along their vector, radial ones honouring /// The colour of `paint` at `p` (gradients are sampled along their vector, radial ones honouring
/// their aspect ratio). /// their aspect ratio).
@ -41,19 +49,31 @@ impl Tool for EyedropperTool {
let Some(h) = hit_test(cx.doc, ev.pos, opts) else { return vec![] }; let Some(h) = hit_test(cx.doc, ev.pos, opts) else { return vec![] };
let src = paint_owner(cx.doc, h.leaf); let src = paint_owner(cx.doc, h.leaf);
let Some(n) = cx.doc.node(src) else { return vec![] }; let Some(n) = cx.doc.node(src) else { return vec![] };
if ev.mods.shift { let stop = self.stop.as_ref().zip(cx.gradient_stop);
if ev.mods.shift || stop.is_some() {
let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none()); let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none());
let Some((paint, to_doc, bounds)) = n.proxy_paint(h.kind == HitKind::Stroke || fill_none, None) else { return vec![] }; let Some((paint, to_doc, bounds)) = n.proxy_paint(h.kind == HitKind::Stroke || fill_none, None) else { return vec![] };
// Sample in the paint's own space (text space for type runs) and box. // Sample in the paint's own space (text space for type runs) and box.
let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * ev.pos } else { ev.pos }; let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * ev.pos } else { ev.pos };
return match paint_color_at(paint, Some(bounds), p) { let Some(c) = paint_color_at(paint, Some(bounds), p) else { return vec![] };
Some(c) => vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))], let Some((back, i)) = stop else { return vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))] };
None => vec![], let out = vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex(), "stop": i })), Action::SwitchTool(back.clone())];
}; self.stop = None;
return out;
} }
vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))] vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))]
} }
fn options(&self) -> Value {
json!({ "stop": self.stop })
}
fn set_option(&mut self, key: &str, value: &Value) {
if key == "stop" {
self.stop = value.as_str().map(str::to_string);
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Eyedropper Cursor::Eyedropper
} }
@ -71,7 +91,7 @@ mod tests {
let s = Selection::default(); let s = Selection::default();
let p = paint(); let p = paint();
let cx = cx(&d, &s, &p); let cx = cx(&d, &s, &p);
let mut t = EyedropperTool; let mut t = EyedropperTool::default();
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
assert_eq!(a, vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0}))]); assert_eq!(a, vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0}))]);
let shift = Mods { shift: true, ..Default::default() }; let shift = Mods { shift: true, ..Default::default() };
@ -83,6 +103,26 @@ mod tests {
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty()); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
} }
#[test]
fn the_stop_option_samples_into_the_selected_stop_then_switches_back() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
let mut t = EyedropperTool::default();
t.set_option("stop", &json!("gradient"));
assert_eq!(t.options(), json!({"stop": "gradient"}));
// Without a selected stop the click copies the appearance as usual.
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
assert!(matches!(&a[0], Action::Exec(c, _) if c == "appearance.copyFrom"));
cx.gradient_stop = Some(1);
// Empty canvas: still waiting.
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 150.0));
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#000000", "stop": 1})), Action::SwitchTool("gradient".into())]);
assert_eq!(t.options(), json!({"stop": null}), "one sample only");
}
#[test] #[test]
fn gradient_sampled_along_vector() { fn gradient_sampled_along_vector() {
let g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()); let g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
@ -109,7 +149,7 @@ mod tests {
let (s, p) = (Selection::default(), paint()); let (s, p) = (Selection::default(), paint());
let shift = Mods { shift: true, ..Default::default() }; let shift = Mods { shift: true, ..Default::default() };
// 80 % along the baseline (text space (80, -5)). // 80 % along the baseline (text space (80, -5)).
let a = EyedropperTool.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 105.0, 180.0).with_mods(shift)); let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 105.0, 180.0).with_mods(shift));
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#333333"}))]); assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#333333"}))]);
} }

View File

@ -1,13 +1,14 @@
//! Gradient tool (G) and the gradient annotator. //! Gradient tool (G) and the gradient annotator.
//! //!
//! Drag across selected objects to set the gradient vector of the paint behind the active proxy //! Drag across selected objects to set the gradient vector of the paint behind the active proxy
//! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. Shift //! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. With nothing
//! constrains the angle to 45°. With nothing selected, the press targets the object under the //! selected, the press targets the object under the
//! pointer; a click inside selected art applies the gradient from that point. //! pointer; a click inside selected art applies the gradient from that point.
//! //!
//! The annotator is a bar from a round start handle to a square end handle. Drag the start handle //! The annotator is a bar from a round start handle to a square end handle. Drag the start handle
//! (or the bar) to move the gradient, the end handle to change its length and angle, and just past //! (or the bar) to move the gradient, the end handle to change its length and angle, and just past
//! the end to rotate it (Shift snaps to 45°). Colour stops sit under the bar: click the bar to add //! the end to rotate it. Dragged handles snap to anchors, edges and smart guides; Shift instead
//! constrains the vector to 45° steps from the Constrain Angle preference. Colour stops sit under the bar: click the bar to add
//! one, drag one to move it (Alt drags a copy), drag it off the bar to delete it, and drag a //! one, drag one to move it (Alt drags a copy), drag it off the bar to delete it, and drag a
//! diamond above the bar to move a midpoint. Double-clicking a stop opens its popover. The //! diamond above the bar to move a midpoint. Double-clicking a stop opens its popover. The
//! selected stop (`gradient.selectStop`) is shared with the Gradient and Color panels: Delete or //! selected stop (`gradient.selectStop`) is shared with the Gradient and Color panels: Delete or
@ -18,9 +19,10 @@ use vectorcraft_color::gradient::{duplicate_stop, insert_stop, midpoint_from_pos
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop}; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop};
use vectorcraft_doc::NodeId; use vectorcraft_doc::NodeId;
use vectorcraft_doc::hit::hit_test; use vectorcraft_doc::hit::hit_test;
use vectorcraft_geom::{Affine, Point, Vec2}; use vectorcraft_geom::{Affine, Point, Vec2, constrain_angle_from};
use super::paint_owner; use super::paint_owner;
use crate::guides::snap_draw;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
const BAR: [u8; 3] = [0x20, 0x20, 0x20]; const BAR: [u8; 3] = [0x20, 0x20, 0x20];
@ -181,6 +183,8 @@ struct Gesture {
#[derive(Default)] #[derive(Default)]
pub struct GradientTool { pub struct GradientTool {
gesture: Option<Gesture>, gesture: Option<Gesture>,
/// Smart-guide feedback of the handle being dragged.
guides: Vec<Overlay>,
} }
/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop. /// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop.
@ -260,7 +264,9 @@ impl GradientTool {
Grab::Art Grab::Art
} }
}; };
self.gesture = Some(Gesture { grab, at: p, began: false, vector: None, stop: None }); // A vector drawn on the art starts on the snapped point.
let at = if grab == Grab::Art { snap_draw(cx, p, &[]).0 } else { p };
self.gesture = Some(Gesture { grab, at, began: false, vector: None, stop: None });
out out
} }
@ -274,20 +280,33 @@ impl GradientTool {
g.began = true; g.began = true;
out.push(Action::Begin("Gradient".into())); out.push(Action::Begin("Gradient".into()));
} }
let snap = |from: Point, to: Point| if mods.shift { from + vectorcraft_geom::constrain_angle(to - from, 45.0) } else { to }; let constrain = |v: Vec2| constrain_angle_from(v, 45.0, cx.constrain_angle);
// Where a handle dragged to `to` lands: Shift constrains the vector from `from`, else it
// snaps (keeping the guides to show).
let mut guides = vec![];
let mut place = |from: Point, to: Point| {
if mods.shift {
from + constrain(to - from)
} else {
let (q, o) = snap_draw(cx, to, &[]);
guides = o;
q
}
};
let (cmd, params) = match &g.grab { let (cmd, params) = match &g.grab {
Grab::Art => { Grab::Art => {
let end = snap(g.at, p); let end = place(g.at, p);
g.vector = Some((g.at, end)); g.vector = Some((g.at, end));
("paint.setGradientGeom", Self::geom_params(cx, g.at, end)) ("paint.setGradientGeom", Self::geom_params(cx, g.at, end))
} }
Grab::Move { geom, .. } => { Grab::Move { geom, .. } => {
let d = p - g.at; // The start handle snaps; Shift constrains the move.
("paint.setGradientGeom", Self::geom_params(cx, geom.start + d, geom.end + d)) let start = place(geom.start, geom.start + (p - g.at));
("paint.setGradientGeom", Self::geom_params(cx, start, start + (geom.end - geom.start)))
} }
Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, snap(geom.start, p))), Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, place(geom.start, p))),
Grab::Rotate(geom) => { Grab::Rotate(geom) => {
let dir = snap(geom.start, p) - geom.start; let dir = if mods.shift { constrain(p - geom.start) } else { p - geom.start };
let len = dir.hypot(); let len = dir.hypot();
let end = if len < 1e-12 { geom.end } else { geom.start + dir * (geom.length() / len) }; let end = if len < 1e-12 { geom.end } else { geom.start + dir * (geom.length() / len) };
("paint.setGradientGeom", Self::geom_params(cx, geom.start, end)) ("paint.setGradientGeom", Self::geom_params(cx, geom.start, end))
@ -313,11 +332,13 @@ impl GradientTool {
("paint.editGradient", Self::stops_params(cx, &set_midpoint(stops, *index, m))) ("paint.editGradient", Self::stops_params(cx, &set_midpoint(stops, *index, m)))
} }
}; };
self.guides = guides;
out.push(Action::Preview(cmd.into(), params)); out.push(Action::Preview(cmd.into(), params));
out out
} }
fn release(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> { fn release(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
self.guides.clear();
let Some(g) = self.gesture.take() else { return vec![] }; let Some(g) = self.gesture.take() else { return vec![] };
if g.began { if g.began {
let mut out = vec![Action::Commit]; let mut out = vec![Action::Commit];
@ -386,6 +407,7 @@ impl Tool for GradientTool {
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> { fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.busy() { if key == ToolKey::Escape && self.busy() {
self.gesture = None; self.gesture = None;
self.guides.clear();
return vec![Action::Cancel]; return vec![Action::Cancel];
} }
if self.busy() || !self.claims_key(cx, key) { if self.busy() || !self.claims_key(cx, key) {
@ -411,14 +433,16 @@ impl Tool for GradientTool {
} }
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> { fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) { let mut o = match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) {
(Some(a), _) => annotator_overlays(cx, &a), (Some(a), _) => annotator_overlays(cx, &a),
// A vector drawn on a paint that isn't a gradient yet. // A vector drawn on a paint that isn't a gradient yet.
(None, Some((s, e))) => { (None, Some((s, e))) => {
annotator_overlays(cx, &Annotator::with(GradientGeom { start: s, end: e, aspect: 1.0 }, &Gradient::default().stops)) annotator_overlays(cx, &Annotator::with(GradientGeom { start: s, end: e, aspect: 1.0 }, &Gradient::default().stops))
} }
_ => vec![], _ => vec![],
} };
o.extend(self.guides.iter().cloned());
o
} }
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
@ -581,6 +605,39 @@ mod tests {
assert!(end.distance(Point::new(100.0, 50.0)) < 1e-9, "{end:?}"); assert!(end.distance(Point::new(100.0, 50.0)) < 1e-9, "{end:?}");
} }
#[test]
fn handles_snap_to_anchors_and_shift_uses_the_constrain_angle() {
let (d, s) = graded(two());
let p = paint();
let mut cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
// The end handle dragged near the rectangle's corner lands on it, with a guide label.
t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0)));
let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(202.0, 197.0)))).clone();
assert_eq!(point(&v["end"]), Point::new(200.0, 200.0));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "anchor")));
t.pointer(&cx, &ev(PointerKind::Up, Point::new(202.0, 197.0)));
assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { .. })), "the guides go with the drag");
// A vector drawn from near a corner starts on it.
let (d2, id) = doc_with_rect();
let mut s2 = Selection::default();
s2.add(id);
let cx2 = crate::testutil::cx(&d2, &s2, &p);
t.pointer(&cx2, &ev(PointerKind::Down, Point::new(101.0, 102.0)));
let v = preview(&t.pointer(&cx2, &ev(PointerKind::Drag, Point::new(150.0, 130.0)))).clone();
assert_eq!(point(&v["start"]), Point::new(100.0, 100.0));
t.pointer(&cx2, &ev(PointerKind::Up, Point::new(150.0, 130.0)));
// Constrain Angle 30: Shift gives 30 and 75 degrees (and no snapping).
cx.constrain_angle = 30.0;
let angle = |v: &Value| GradientGeom { start: point(&v["start"]), end: point(&v["end"]), aspect: 1.0 }.angle_deg();
for (to, want) in [(Point::new(190.0, 100.0), 30.0), (Point::new(125.0, 60.0), 75.0)] {
t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0)));
let v = preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, to)))).clone();
assert!((angle(&v) - want).abs() < 1e-9, "{want}: {v}");
t.pointer(&cx, &ev(PointerKind::Up, to));
}
}
#[test] #[test]
fn a_bar_click_adds_a_stop_and_selects_it() { fn a_bar_click_adds_a_stop_and_selects_it() {
let (d, s) = graded(two()); let (d, s) = graded(two());

View File

@ -136,6 +136,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
handle_input(app, ui, &resp, rect); handle_input(app, ui, &resp, rect);
let v = *app.view().unwrap_or(&View::default()); let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v); let xf = Xf::new(rect, &v);
panel_drop(app, ui, &resp, &xf);
let painter = ui.painter_at(rect); let painter = ui.painter_at(rect);
let Some(st) = app.session.active() else { return }; let Some(st) = app.session.active() else { return };
let doc = st.doc.clone(); let doc = st.doc.clone();
@ -667,14 +668,58 @@ fn node_outline(n: &Node) -> BezPath {
bp bp
} }
/// The topmost editable object under document point `p` at `zoom` (3 px tolerance).
fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::hit::Hit> {
let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / zoom, outline: app.ui.view.outline, path_only: false };
vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt)
}
/// A panel drag ([`widgets::PanelDrag`]) dropped on art acts on the object under the pointer,
/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the
/// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries
/// (`appearance.copyFrom`). A chip follows the pointer meanwhile.
fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) {
crate::panels::swatches::drag_preview(app, ui.ctx());
let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return };
// Not through a floating panel over the canvas.
if ui.ctx().layer_id_at(pos).is_some_and(|l| l != resp.layer_id) {
return;
}
let Some(d) = resp.dnd_release_payload::<widgets::PanelDrag>() else { return };
let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return };
let Some(st) = app.session.active() else { return };
let (cmd, params) = match &*d {
widgets::PanelDrag::Paint { params, .. } => {
// Colour groups paint nothing.
if params.is_null() {
return;
}
let mut params = params.clone();
params["ids"] = json!([vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]);
params["focus"] = json!(false);
(crate::panels::proxy_cmd(app, false), params)
}
widgets::PanelDrag::Appearance(source) => {
let target = hit.top_object(st.isolation);
if target == *source {
return;
}
("appearance.copyFrom", json!({"source": source.0, "ids": [target.0]}))
}
};
if let Err(e) = app.run(cmd, params) {
app.status(e);
}
}
fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(h) = app.hover_doc else { return }; let Some(h) = app.hover_doc else { return };
if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") { if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") {
return; return;
} }
let Some(st) = app.session.active() else { return }; let Some(st) = app.session.active() else { return };
let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / xf.zoom, outline: app.ui.view.outline, path_only: false }; let Some(hit) = hit_at(app, h, xf.zoom) else { return };
let Some(hit) = vectorcraft_doc::hit::hit_test(&st.doc, h, opt) else { return };
let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf }; let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf };
if st.selection.contains(id) { if st.selection.contains(id) {
return; return;
@ -1081,4 +1126,75 @@ mod tests {
assert_eq!(app.view().unwrap().center, after.center); assert_eq!(app.view().unwrap().center, after.center);
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None); assert_eq!(app.session.active().unwrap().doc.art_bounds(), None);
} }
#[test]
fn swatches_and_proxy_paints_dropped_on_art_fill_the_object_hit() {
use vectorcraft_color::{Color, GradientGeom, GradientPaint, Paint};
use widgets::{PanelDrag, SwatchRows};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.session.execute("select.none", &json!({})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let drop = |app: &mut VectorcraftApp, d: PanelDrag, at: Point| {
let at = xf.to_screen(at);
egui::DragAndDrop::set_payload(&ctx, d);
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]);
};
let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint();
let red = Paint::solid(Color::from_hex("#ed1c24").unwrap());
let rows = SwatchRows { grabbed: "Red".into(), names: vec!["Red".into()], groups: false };
let swatch = PanelDrag::Paint { paint: red.clone(), params: json!({"swatch": "Red"}), rows: Some(rows) };
// Off the art nothing happens.
drop(&mut app, swatch.clone(), Point::new(300.0, 250.0));
assert_eq!(fill(&app), Paint::solid(Color::WHITE));
drop(&mut app, swatch, Point::new(100.0, 100.0));
assert_eq!(fill(&app), red, "paint.setFill with the hit id");
assert!(app.session.active().unwrap().selection.is_empty(), "the selection stays as it was");
// A proxy's paint works the same.
drop(&mut app, PanelDrag::paint(Paint::solid(Color::rgb(0.0, 0.0, 1.0))), Point::new(60.0, 60.0));
assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
// A dragged gradient (the Gradient panel's thumbnail, a proxy) fits the object it lands on.
let mut g = GradientPaint::new(Default::default());
g.geom = Some(GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0 });
drop(&mut app, PanelDrag::paint(Paint::Gradient(Box::new(g))), Point::new(100.0, 100.0));
assert!(matches!(fill(&app), Paint::Gradient(g) if g.geom.is_none()));
}
#[test]
fn dropping_the_appearance_thumbnail_on_art_copies_the_appearance() {
use vectorcraft_doc::NodeId;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 400, "height": 300}));
let a = run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap();
run(&mut app, "effect.apply", json!({"effect": "distort.twist"}));
run(&mut app, "transparency.set", json!({"opacity": 40}));
let b = run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap();
run(&mut app, "select.set", json!({ "ids": [a] }));
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
// The Appearance panel's thumbnail (its drag payload) released over the second rectangle.
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let drop = |app: &mut VectorcraftApp, at: Point| {
let at = xf.to_screen(at);
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Appearance(NodeId(a)));
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
frame(app, &ctx, vec![up]);
};
drop(&mut app, Point::new(250.0, 70.0));
let doc = &app.session.active().unwrap().doc;
let (na, nb) = (doc.node(NodeId(a)).unwrap(), doc.node(NodeId(b)).unwrap());
assert_eq!((&nb.appearance, nb.opacity), (&na.appearance, na.opacity));
assert_eq!(nb.appearance.effects[0].id, "distort.twist");
// Dropped on empty canvas or on the source itself: nothing changes.
let undo_len = app.session.active().unwrap().history.undo.len();
drop(&mut app, Point::new(350.0, 250.0));
drop(&mut app, Point::new(70.0, 70.0));
assert_eq!(app.session.active().unwrap().history.undo.len(), undo_len);
}
} }

View File

@ -1,15 +1,115 @@
//! Live effect dialogs (Effect menu): the effect's parameters (`__effect`, headed `__label`) with a //! Live effect dialogs (Effect menu, Appearance panel): the effect's parameters (`__effect`, headed
//! live Preview that OK keeps as one undo step and Cancel rolls back. `__item`, when present, is //! `__label`) with a live Preview that OK keeps as one undo step and Cancel rolls back. `__item`,
//! the appearance item the effect goes to (else the Appearance panel's active item applies). //! when present, is the appearance item the effect goes to (else the Appearance panel's active
//! item applies). With `__index` the dialog edits that applied effect in place
//! (`effect.setParams`) instead of adding one (`effect.apply`).
//!
//! Choosing an effect that the target already carries first asks ([`EXISTS`]) whether to edit it
//! or add another one.
use serde_json::{Value, json}; use serde_json::{Map, Value, json};
use super::{DialogSpec, form}; use super::{DialogSpec, form};
use crate::VectorcraftApp; use crate::VectorcraftApp;
use crate::state::Dialog; use crate::state::Dialog;
use crate::theme::Tokens;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM }; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM };
/// The dialog kind asking whether to edit an effect that is already applied or add another.
/// Fields: `__effect`, `__label`, `__item?` (as given to `effect.dialog`), `__target` and
/// `__index` (the applied effect's item and position). OK edits it; `discard: true` then OK adds
/// a new one.
pub const EXISTS: &str = "effectExists";
pub(super) const EXISTS_SPEC: DialogSpec = DialogSpec {
heading: |d| format!("{} is already applied", d.str("__label")),
body: |_, ui, _| {
ui.label(egui::RichText::new("Edit the applied effect, or add another one?").color(Tokens::get(ui.ctx()).text_dim));
false
},
confirm: confirm_exists,
ok: Some("Edit"),
discard: Some("Add New"),
max_width: Some(420.0),
..DialogSpec::FORM
};
/// `effect.dialog {effect, index?, item?}`: edit applied effect `index` of `item` (null: the
/// object's effects; omitted: the active item's, else the object's) prefilled with its values,
/// or add `effect`, first asking when that list already has it.
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let id = p.get("effect").and_then(Value::as_str).unwrap_or_default();
let info = vectorcraft_effects::effect_info(id).ok_or_else(|| format!("unknown effect `{id}`"))?;
// The list the effect goes to: `item` as given, else the active item's (the object's without
// one). A new effect keeps `item` unresolved, so `effect.apply` targets each object as usual.
let target = p.get("item").cloned().unwrap_or_else(|| json!(app.session.appearance_item()));
let applied = target_effects(app, &target);
let mut fields = Map::new();
fields.insert("__effect".into(), json!(id));
fields.insert("__label".into(), json!(info.label.trim_end_matches('…')));
if let Some(item) = p.get("item") {
fields.insert("__item".into(), item.clone());
}
match p.get("index").and_then(Value::as_u64) {
Some(k) => {
let e = applied.get(k as usize).ok_or_else(|| format!("no effect at index {k}"))?;
if e.id != id {
return Err(format!("effect {k} is `{}`, not `{id}`", e.id));
}
fields.insert("__item".into(), target);
fields.insert("__index".into(), json!(k));
fields.extend(params_of(e));
}
None => {
if let Some(k) = applied.iter().rposition(|e| e.id == id) {
fields.insert("__target".into(), target);
fields.insert("__index".into(), json!(k));
app.ui.dialog = Some(Dialog { kind: EXISTS.into(), fields });
return Ok(json!({ "pending": EXISTS }));
}
fields.extend(info.defaults.as_object().cloned().unwrap_or_default());
}
}
fields.insert("preview".into(), json!(true));
app.ui.dialog = Some(Dialog { kind: "effect".into(), fields });
Ok(Value::Null)
}
/// The effects of the first selected object that `item` (an index or null) addresses.
fn target_effects(app: &VectorcraftApp, item: &Value) -> Vec<vectorcraft_doc::Effect> {
let Some(st) = app.session.active() else { return vec![] };
let node = st.selection.objects.first().and_then(|id| st.doc.node(*id));
node.and_then(|n| n.appearance.effects_at(item.as_u64().map(|i| i as usize))).cloned().unwrap_or_default()
}
/// An applied effect's values over its defaults.
fn params_of(e: &vectorcraft_doc::Effect) -> Map<String, Value> {
match vectorcraft_effects::merged_params(&e.id, &e.params) {
Value::Object(m) => m,
_ => Map::new(),
}
}
/// OK in [`EXISTS`]: the dialog editing the applied effect, or (`discard`) a fresh one.
fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut fields = d.fields.clone();
fields.remove("discard");
let target = fields.remove("__target").unwrap_or(Value::Null);
if d.bool("discard") {
fields.remove("__index");
let defaults = vectorcraft_effects::default_params(&d.str("__effect"));
fields.extend(defaults.and_then(|v| v.as_object().cloned()).unwrap_or_default());
} else {
let k = fields.get("__index").and_then(Value::as_u64).unwrap_or(0) as usize;
fields.extend(target_effects(app, &target).get(k).map(params_of).unwrap_or_default());
fields.insert("__item".into(), target);
}
fields.insert("preview".into(), json!(true));
app.ui.dialog = Some(Dialog { kind: "effect".into(), fields });
Ok(Value::Null)
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let changed = form::param_fields(ui, d); let changed = form::param_fields(ui, d);
ui.add_space(6.0); ui.add_space(6.0);
@ -19,32 +119,42 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if pv && (changed || pv_changed || !app.session.in_interaction()) { if pv && (changed || pv_changed || !app.session.in_interaction()) {
let label = d.str("__label"); let label = d.str("__label");
let _ = app.session.begin_interaction(&label); let _ = app.session.begin_interaction(&label);
let _ = app.session.preview("effect.apply", &apply_params(d)); let (cmd, p) = command(d);
let _ = app.session.preview(cmd, &p);
} else if !pv && pv_changed { } else if !pv && pv_changed {
let _ = app.session.cancel_interaction(); let _ = app.session.cancel_interaction();
} }
false false
} }
/// `effect.apply` params for the dialog's effect, values and target item. /// What OK runs: `effect.setParams` on the edited effect (`__index`), else `effect.apply` of the
fn apply_params(d: &Dialog) -> Value { /// dialog's effect with its values on the target item.
let mut p = json!({"effect": d.str("__effect"), "params": form::params(d)}); fn command(d: &Dialog) -> (&'static str, Value) {
if let Some(item) = d.fields.get("__item") { let params = form::params(d);
p["item"] = item.clone(); let item = d.fields.get("__item").cloned();
if let Some(index) = d.fields.get("__index") {
return ("effect.setParams", json!({"index": index, "item": item.unwrap_or(Value::Null), "params": params}));
} }
p let mut p = json!({"effect": d.str("__effect"), "params": params});
if let Some(item) = item {
p["item"] = item;
}
("effect.apply", p)
} }
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let p = apply_params(d); let (cmd, p) = command(d);
let r = if app.session.in_interaction() { let r = if app.session.in_interaction() {
// Live preview already applied: keep it (the interaction commits as one undo step). // Live preview already applied: keep it (the interaction commits as one undo step).
let _ = app.session.preview("effect.apply", &p); let _ = app.session.preview(cmd, &p);
app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()) app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string())
} else { } else {
app.run("effect.apply", p.clone()) app.run(cmd, p.clone())
}; };
app.last_effect = Some((d.str("__effect"), p["params"].clone())); // Last Effect repeats applied effects, not edits.
if cmd == "effect.apply" {
app.last_effect = Some((d.str("__effect"), p["params"].clone()));
}
app.ui.dialog = None; app.ui.dialog = None;
r r
} }

View File

@ -1,9 +1,10 @@
//! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator edits it //! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator (or the
//! next to its chip — its colour (the Color panel's controls, or a document swatch), opacity and //! Gradient panel's slider) edits it next to its chip — its colour (the Color panel's controls,
//! location. It edits the selected stop (`gradient.selectStop`) through `paint.editGradient`, and //! or a document swatch), opacity and location. It edits the selected stop
//! closes on Escape or a click elsewhere. //! (`gradient.selectStop`) through `paint.editGradient`, and closes on Escape or a click elsewhere.
//! //!
//! Fields: `index`, `x`, `y` (the chip, document coordinates) and `tab` (`color` or `swatches`). //! Fields: `index`, `x`, `y` (the chip, document coordinates) or `screen` ([x, y], screen points,
//! from the panel), and `tab` (`color` or `swatches`).
//! Agents can set `color` (hex), `opacity` and `location` (percentages) and confirm. //! Agents can set `color` (hex), `opacity` and `location` (percentages) and confirm.
use egui::{Sense, vec2}; use egui::{Sense, vec2};
@ -15,8 +16,8 @@ use vectorcraft_geom::Point;
use super::{DialogResult, DialogSpec}; use super::{DialogResult, DialogSpec};
use crate::VectorcraftApp; use crate::VectorcraftApp;
use crate::canvas::Xf; use crate::canvas::Xf;
use crate::panels::gradient::stops_json; use crate::panels::active_paint;
use crate::panels::{active_paint, live_run}; use crate::panels::gradient::set_stops;
use crate::state::Dialog; use crate::state::Dialog;
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::widgets::{self, Live}; use crate::widgets::{self, Live};
@ -30,15 +31,6 @@ fn selected(app: &VectorcraftApp) -> Option<(Vec<GradientStop>, usize)> {
Some((g.gradient.stops, i)) Some((g.gradient.stops, i))
} }
/// Write `stops` to the gradient and select stop `i`.
fn apply(app: &mut VectorcraftApp, stops: &[GradientStop], i: usize, phase: Live) {
let params = json!({ "stops": stops_json(stops), "stroke": !app.session.fill_active });
live_run(app, "Gradient", "paint.editGradient", params, phase);
if phase == Live::Released && app.session.selected_stop() != Some(i) {
app.run("gradient.selectStop", json!({ "index": i })).ok();
}
}
/// `stops` with stop `i` given `color`. /// `stops` with stop `i` given `color`.
fn recolor(mut stops: Vec<GradientStop>, i: usize, color: Color) -> Vec<GradientStop> { fn recolor(mut stops: Vec<GradientStop>, i: usize, color: Color) -> Vec<GradientStop> {
stops[i].color = color; stops[i].color = color;
@ -60,9 +52,13 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
return; return;
}; };
let t = Tokens::get(ctx); let t = Tokens::get(ctx);
// Opened from the Gradient panel: at `screen`; from the annotator: beside the chip.
let screen =
d.fields.get("screen").and_then(Value::as_array).and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)));
let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0)); let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0));
let pos = let pos = screen.unwrap_or_else(|| {
app.canvas_rect.zip(app.view().copied()).map_or(ctx.content_rect().center(), |(r, v)| Xf::new(r, &v).to_screen(chip) + vec2(14.0, 14.0)); app.canvas_rect.zip(app.view().copied()).map_or(ctx.content_rect().center(), |(r, v)| Xf::new(r, &v).to_screen(chip) + vec2(14.0, 14.0))
});
let swatches = d.str("tab") == "swatches"; let swatches = d.str("tab") == "swatches";
let mut tab = None; let mut tab = None;
let resp = egui::Area::new(egui::Id::new("gradient-stop-popover")) let resp = egui::Area::new(egui::Id::new("gradient-stop-popover"))
@ -93,12 +89,12 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if let Some(v) = widgets::plain_field(ui, "stop-pop-op", s.opacity as f64 * 100.0, "%", 0, 54.0) { if let Some(v) = widgets::plain_field(ui, "stop-pop-op", s.opacity as f64 * 100.0, "%", 0, 54.0) {
let mut v2 = stops.clone(); let mut v2 = stops.clone();
v2[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32; v2[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32;
apply(app, &v2, i, Live::Released); set_stops(app, &v2, Some(i), Live::Released);
} }
widgets::dim_label(ui, "Location:"); widgets::dim_label(ui, "Location:");
if let Some(v) = widgets::plain_field(ui, "stop-pop-loc", s.offset as f64 * 100.0, "%", 1, 54.0) { if let Some(v) = widgets::plain_field(ui, "stop-pop-loc", s.offset as f64 * 100.0, "%", 1, 54.0) {
let (v2, ni) = move_stop(&stops, i, (v / 100.0) as f32); let (v2, ni) = move_stop(&stops, i, (v / 100.0) as f32);
apply(app, &v2, ni, Live::Released); set_stops(app, &v2, Some(ni), Live::Released);
} }
}); });
}); });
@ -133,7 +129,7 @@ fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, stops: &[GradientSto
} }
}); });
if let Some(c) = pick { if let Some(c) = pick {
apply(app, &recolor(stops.to_vec(), i, c), i, Live::Released); set_stops(app, &recolor(stops.to_vec(), i, c), Some(i), Live::Released);
} }
} }
@ -150,7 +146,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
if d.fields.contains_key("location") { if d.fields.contains_key("location") {
(stops, i) = move_stop(&stops, i, (d.f64("location", 0.0) / 100.0) as f32); (stops, i) = move_stop(&stops, i, (d.f64("location", 0.0) / 100.0) as f32);
} }
apply(app, &stops, i, Live::Released); set_stops(app, &stops, Some(i), Live::Released);
app.ui.dialog = None; app.ui.dialog = None;
Ok(json!({ "index": i })) Ok(json!({ "index": i }))
} }

View File

@ -0,0 +1,97 @@
//! Graphic Style Options: the style's name. Opened on a style it renames it (`graphicStyle.rename`,
//! linked objects stay linked); opened for New Graphic Style it names the style made from the
//! selection (`graphicStyle.new`).
//!
//! Fields: `name` and `__style` (the style being renamed; empty for a new style).
use serde_json::{Value, json};
use super::swatch_options::{grid, label};
use super::{DialogSpec, form};
use crate::VectorcraftApp;
use crate::state::Dialog;
/// The dialog kind of Graphic Style Options.
pub const KIND: &str = "graphicStyleOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Graphic Style Options".into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
/// Open Graphic Style Options for style `name`, or (`None`) to name a new style from the selection.
pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, String> {
let d = &app.session.active().ok_or("no document open")?.doc;
let name = match name {
Some(n) => d.graphic_style(n).ok_or_else(|| format!("no graphic style `{n}`"))?.name.clone(),
None => d.new_graphic_style_name(),
};
let style = if d.graphic_style(&name).is_some() { name.clone() } else { String::new() };
app.ui.dialog = Some(Dialog::new(KIND, json!({ "name": name, "__style": style })));
Ok(Value::Null)
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
grid(ui, |ui| {
label(ui, "Style Name:");
form::text(ui, d, "name", 190.0);
ui.end_row();
});
false
}
/// OK: name the new style or rename the style. A name another style has keeps the dialog open.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (name, style) = (d.str("name"), d.str("__style"));
let r = match style.as_str() {
"" => app.run("graphicStyle.new", json!({ "name": name })),
s if s == name => Ok(Value::Null),
s => app.run("graphicStyle.rename", json!({ "name": s, "to": name })),
};
if r.is_ok() {
app.ui.dialog = None;
}
r
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
app
}
fn set(app: &mut VectorcraftApp, v: &str) {
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!(v));
}
fn names(app: &VectorcraftApp) -> Vec<String> {
app.session.active().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect()
}
#[test]
fn names_a_new_style_then_renames_it() {
let mut app = app();
app.run("ui.graphicStyleOptions", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Graphic Style 5");
// Drawn headlessly in the shared frame.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(&mut app, ui.ctx()));
out.textures_delta.clear();
set(&mut app, "Glow");
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none() && names(&app).last().unwrap() == "Glow");
app.run("ui.graphicStyleOptions", json!({"name": "Glow"})).unwrap();
set(&mut app, "Sunshine");
assert!(super::super::confirm(&mut app).is_err(), "names are unique");
assert!(app.ui.dialog.is_some(), "a taken name keeps the dialog open");
set(&mut app, "Halo");
super::super::confirm(&mut app).unwrap();
assert_eq!(names(&app).last().unwrap(), "Halo");
assert!(app.run("ui.graphicStyleOptions", json!({"name": "nope"})).is_err());
}
}

View File

@ -17,6 +17,7 @@ mod effect;
mod export_for_screens; mod export_for_screens;
mod form; mod form;
mod gradient_stop; mod gradient_stop;
pub mod graphic_style_options;
pub mod new_color_group; pub mod new_color_group;
mod new_document; mod new_document;
pub mod new_swatch; pub mod new_swatch;
@ -31,6 +32,7 @@ mod transform;
use serde_json::{Value, json}; use serde_json::{Value, json};
pub use color_picker::open as open_color_picker; pub use color_picker::open as open_color_picker;
pub use effect::open as open_effect_dialog;
pub use tools::open_tool_dialog; pub use tools::open_tool_dialog;
use crate::state::Dialog; use crate::state::Dialog;
@ -140,6 +142,8 @@ registry! {
NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC, NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC,
GradientStop: ["gradientStop"] => gradient_stop::SPEC, GradientStop: ["gradientStop"] => gradient_stop::SPEC,
ColorPicker: [color_picker::KIND] => color_picker::SPEC, ColorPicker: [color_picker::KIND] => color_picker::SPEC,
GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC,
EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC,
} }
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).

View File

@ -156,7 +156,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"effect.dialog", "effect.dialog",
"Effect…", "Effect…",
"", "",
"{effect: id, item?: appearance item index|null (default: the Appearance panel's active item)} open the effect's dialog with live preview", "{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}",
), ),
("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"),
("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"), ("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"),
@ -189,6 +189,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"", "",
"{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke", "{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke",
), ),
(
"ui.graphicStyleOptions",
"Graphic Style Options…",
"",
"{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)",
),
(
"tool.options",
"Tool Options…",
"",
"{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `command` running eyedropper.setOptions, fields appearance, transparency)",
),
]; ];
/// Handle a UI command. `None` = not a UI command (the engine handles it). /// Handle a UI command. `None` = not a UI command (the engine handles it).
@ -460,23 +472,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
app.session.set_tool_option(&k, p.get("value").unwrap_or(&Value::Null)); app.session.set_tool_option(&k, p.get("value").unwrap_or(&Value::Null));
Ok(app.session.tool_options()) Ok(app.session.tool_options())
} }
"effect.dialog" => { "effect.dialog" => crate::dialogs::open_effect_dialog(app, p),
let id = s("effect").unwrap_or_default();
match vectorcraft_effects::effect_catalog().into_iter().find(|e| e.id == id) {
Some(e) => {
let mut fields = e.defaults.as_object().cloned().unwrap_or_default();
fields.insert("__effect".into(), json!(e.id));
fields.insert("__label".into(), json!(e.label.trim_end_matches('…')));
fields.insert("preview".into(), json!(true));
if let Some(item) = p.get("item") {
fields.insert("__item".into(), item.clone());
}
app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields });
Ok(Value::Null)
}
None => Err(format!("unknown effect `{id}`")),
}
}
"ui.recolorDialog" => match app.run("recolor.colors", json!({})) { "ui.recolorDialog" => match app.run("recolor.colors", json!({})) {
Ok(v) => { Ok(v) => {
let pairs: Vec<Value> = v["colors"].as_array().cloned().unwrap_or_default().iter().map(|c| json!([c["hex"], c["hex"]])).collect(); let pairs: Vec<Value> = v["colors"].as_array().cloned().unwrap_or_default().iter().map(|c| json!([c["hex"], c["hex"]])).collect();
@ -524,6 +520,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
crate::dialogs::new_color_group::open(app, names.unwrap_or_default()) crate::dialogs::new_color_group::open(app, names.unwrap_or_default())
} }
"ui.colorPicker" => crate::dialogs::open_color_picker(app, p), "ui.colorPicker" => crate::dialogs::open_color_picker(app, p),
"ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()),
"tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()),
_ => return None, _ => return None,
}; };
Some(r) Some(r)
@ -687,6 +685,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()),
"file.export.pdf" => app.session.active().is_some(), "file.export.pdf" => app.session.active().is_some(),
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
"ui.graphicStyleOptions" => app.session.active().is_some(),
_ => true, _ => true,
} }
} }
@ -1077,14 +1076,14 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
"Same", "Same",
vec![ vec![
c("Appearance", "select.same.appearance"), c("Appearance", "select.same.appearance"),
todo("Appearance Attribute"), c("Appearance Attribute", "select.same.appearanceAttribute"),
c("Blending Mode", "select.same.blendingMode"), c("Blending Mode", "select.same.blendingMode"),
c("Fill & Stroke", "select.same.fillAndStroke"), c("Fill & Stroke", "select.same.fillAndStroke"),
c("Fill Color", "select.same.fillColor"), c("Fill Color", "select.same.fillColor"),
c("Opacity", "select.same.opacity"), c("Opacity", "select.same.opacity"),
c("Stroke Color", "select.same.strokeColor"), c("Stroke Color", "select.same.strokeColor"),
c("Stroke Weight", "select.same.strokeWeight"), c("Stroke Weight", "select.same.strokeWeight"),
todo("Graphic Style"), c("Graphic Style", "select.same.graphicStyle"),
c("Shape", "select.same.shapeType"), c("Shape", "select.same.shapeType"),
c("Symbol Instance", "select.same.symbolInstance"), c("Symbol Instance", "select.same.symbolInstance"),
Sep, Sep,

View File

@ -1,6 +1,11 @@
//! Appearance panel: the full stack like Illustrator — the object row, each stroke/fill row (eye, //! Appearance panel: the full stack as in the reference app — the object row (its thumbnail drags
//! disclosure, link label, swatch, weight) with "Opacity: Default" sub-rows, fx rows with inline //! the appearance onto art), each stroke/fill row (eye, disclosure, link label, swatch, weight,
//! parameter editors (effect.setParams), the object's Opacity row and the bottom bar. //! brush/dash/profile notes) with "Opacity: Default" sub-rows, fx rows (the name opens the
//! effect's dialog; the disclosure an inline editor), the object's Opacity row and the bottom bar.
//!
//! Rows drag: a fill/stroke row reorders the stack, an effect row reorders its list or moves into
//! another item (dropped on its row) or onto the object; Alt copies instead. Cmd-click and
//! Shift-click select several fills/strokes for Duplicate and Delete.
use std::sync::OnceLock; use std::sync::OnceLock;
@ -10,8 +15,9 @@ use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId};
use super::{first_selected, live_run, pstate, set_pstate}; use super::{first_selected, live_run, pstate, set_pstate};
use crate::panels::stroke::paint_profile;
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::widgets::{self, TransparencyEdit, menu_item}; use crate::widgets::{self, PanelDrag, TransparencyEdit, menu_item};
use crate::{VectorcraftApp, icons}; use crate::{VectorcraftApp, icons};
const ROW: f32 = 30.0; const ROW: f32 = 30.0;
@ -64,12 +70,56 @@ fn current_sel(app: &VectorcraftApp, ctx: &egui::Context, node: Option<&Node>) -
} }
/// Select a row; its fill/stroke becomes the active item that paint and effect edits target. /// Select a row; its fill/stroke becomes the active item that paint and effect edits target.
/// Other selected fills/strokes are deselected.
pub(crate) fn select_row(app: &mut VectorcraftApp, ctx: &egui::Context, sel: Sel) { pub(crate) fn select_row(app: &mut VectorcraftApp, ctx: &egui::Context, sel: Sel) {
if app.session.appearance_item() != sel.item() { if app.session.appearance_item() != sel.item() {
app.run("appearance.setActiveItem", json!({ "index": sel.item() })).ok(); app.run("appearance.setActiveItem", json!({ "index": sel.item() })).ok();
} }
let owner = app.session.active().and_then(|d| d.selection.objects.first().copied()); let owner = app.session.active().and_then(|d| d.selection.objects.first().copied());
set_pstate(ctx, "ap-sel", (owner, sel)); set_pstate(ctx, "ap-sel", (owner, sel));
set_pstate(ctx, "ap-multi", (owner, sel.item().into_iter().collect::<Vec<usize>>()));
}
/// The selected fill/stroke rows of `n` (paint order, ascending): the active row plus those
/// Cmd/Shift-clicked with it.
fn selected_items(ctx: &egui::Context, n: Option<&Node>, sel: Sel) -> Vec<usize> {
let (Some(n), Sel::Item(active)) = (n, sel) else { return vec![] };
let (owner, mut set): (Option<NodeId>, Vec<usize>) = pstate(ctx, "ap-multi");
if owner != Some(n.id) || !set.contains(&active) {
return vec![active];
}
set.retain(|i| *i < n.appearance.items.len());
set.sort_unstable();
set.dedup();
set
}
/// Click on fill/stroke row `i`: Shift extends the selection from the active row, Cmd toggles
/// the row, a plain click selects only it.
fn click_item(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, sel: Sel, i: usize, m: egui::Modifiers) {
let mut set = selected_items(ctx, Some(n), sel);
let active = match sel {
Sel::Item(a) if m.shift => {
set = (a.min(i)..=a.max(i)).collect();
a
}
Sel::Item(a) if m.command => {
match set.iter().position(|x| *x == i) {
Some(p) if set.len() > 1 => {
set.remove(p);
}
Some(_) => {}
None => set.push(i),
}
if set.contains(&a) { a } else { set[0] }
}
_ => {
set = vec![i];
i
}
};
select_row(app, ctx, Sel::Item(active));
set_pstate(ctx, "ap-multi", (Some(n.id), set));
} }
fn catalog() -> &'static [(String, String, Vec<String>)] { fn catalog() -> &'static [(String, String, Vec<String>)] {
@ -175,16 +225,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border));
let mixed = mixed_appearances(app); let mixed = mixed_appearances(app);
let label = if mixed { "Mixed Appearances" } else { object_label(app) }; let label = if mixed { "Mixed Appearances" } else { object_label(app) };
// A linked object names its graphic style ("Rectangle: Sunshine").
let label = match app.session.selection_graphic_style() {
Some((g, true)) if !mixed => std::borrow::Cow::Owned(format!("{label}: {}", g.name)),
_ => std::borrow::Cow::Borrowed(label),
};
if !hide_thumb { if !hide_thumb {
let th = Rect::from_min_size(r.left_center() + vec2(6.0, -12.0), vec2(24.0, 24.0)); let th = Rect::from_min_size(r.left_center() + vec2(6.0, -12.0), vec2(24.0, 24.0));
let fill = node.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or_else(|| app.session.paint.fill.clone()); let fill = node.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or_else(|| app.session.paint.fill.clone());
chip(ui, th, &fill); chip(ui, th, &fill);
if let Some(n) = node.as_ref().filter(|_| !mixed) {
thumbnail_drag(ui, th, n.id);
}
} }
text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), label, true); text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), &label, true);
match &node { match &node {
// Objects that differ have no common stack to list. // Objects that differ have no common stack to list.
Some(_) if mixed => {} Some(_) if mixed => {}
Some(n) => stack(app, ui, n, sel), Some(n) => stack(app, ui, n, sel, r),
None => default_stack(app, ui), None => default_stack(app, ui),
} }
// Spacer row like Illustrator's empty tail. // Spacer row like Illustrator's empty tail.
@ -193,6 +251,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
bottom(app, ui, node.as_ref(), sel); bottom(app, ui, node.as_ref(), sel);
} }
/// The object row's thumbnail drags this object's appearance onto art ([`PanelDrag::Appearance`];
/// a chip of its fill follows the pointer).
fn thumbnail_drag(ui: &mut Ui, th: Rect, id: NodeId) {
let resp = ui.interact(th, ui.id().with("ap-thumb"), Sense::drag()).on_hover_text("Drag onto art to apply this appearance");
widgets::drag_source(ui, &resp, || PanelDrag::Appearance(id));
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
}
/// The selection as the Control bar and the object row name it: "No Selection", "Mixed Objects" /// The selection as the Control bar and the object row name it: "No Selection", "Mixed Objects"
/// or the object's kind ("Path", "Type", …). /// or the object's kind ("Path", "Type", …).
pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str { pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str {
@ -229,16 +297,43 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) {
text(ui, r.left_center() + vec2(EYE_W + 24.0, 0.0), "Opacity: Default", false); text(ui, r.left_center() + vec2(EYE_W + 24.0, 0.0), "Opacity: Default", false);
} }
fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { /// A stack row being dragged.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Dragged {
Item(usize),
/// Effect `k` of fill/stroke `item` (`None`: of the object).
Effect(Option<usize>, usize),
}
/// What a stack row stands for when a dragged row is dropped on it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Slot {
/// Fill/stroke row `index` (`sub`: one of its indented sub-rows).
Item { index: usize, sub: bool },
/// Effect `k` of fill/stroke `item` (`None`: of the object).
Effect(Option<usize>, usize),
/// The object row (`top`) or the object's Opacity row.
Object { top: bool },
}
fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: Rect) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
let mut drop: Option<(usize, usize)> = None; let ctx = ui.ctx().clone();
let dragging: Option<Dragged> = pstate(&ctx, "ap-drag");
let items = selected_items(&ctx, Some(n), sel);
let mut rows = vec![(Slot::Object { top: true }, object_row)];
let mut stopped = false; let mut stopped = false;
let dragging: Option<usize> = pstate(ui.ctx(), "ap-drag"); // Starts or ends a row drag.
let mut row_rects = vec![]; let mut track = |resp: &egui::Response, what: Dragged| {
if resp.drag_started() {
set_pstate(&ctx, "ap-drag", Some(what));
}
stopped |= resp.drag_stopped();
};
for (i, it) in n.appearance.items.iter().enumerate().rev() { for (i, it) in n.appearance.items.iter().enumerate().rev() {
let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}")); let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}"));
let (r, resp) = row(ui, sel == Sel::Item(i)); let (r, resp) = row(ui, items.contains(&i));
row_rects.push((i, r)); rows.push((Slot::Item { index: i, sub: false }, r));
let (visible, paint) = (it.visible(), it.paint()); let (visible, paint) = (it.visible(), it.paint());
let is_stroke = !it.is_fill(); let is_stroke = !it.is_fill();
if eye(ui, r, ("ap-eye", i), visible, true) { if eye(ui, r, ("ap-eye", i), visible, true) {
@ -256,11 +351,19 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
} else { } else {
text(ui, pos2(lx, r.center().y), "Fill:", false); text(ui, pos2(lx, r.center().y), "Fill:", false);
} }
// Swatch with a swatches popup that sets this item's paint. // Swatch with a swatches popup that sets this item's paint; Shift-click opens the Color
// panel's mixer on this row instead.
let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0)); let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0));
chip(ui, cr, paint); chip(ui, cr, paint);
let cresp = ui.interact(cr.expand(2.0), ui.id().with(("ap-chip", i)), Sense::click()).on_hover_text("Click to choose a swatch"); let cresp = ui
egui::Popup::menu(&cresp).show(|ui| { .interact(cr.expand(2.0), ui.id().with(("ap-chip", i)), Sense::click())
.on_hover_text("Click to choose a swatch, Shift-click to mix a colour");
let mixer = cresp.clicked() && ui.input(|inp| inp.modifiers.shift);
if mixer {
select_row(app, ui.ctx(), Sel::Item(i));
app.ui.open_panel = Some("color".into());
}
egui::Popup::menu(&cresp).open_memory((cresp.clicked() && !mixer).then_some(egui::SetOpenCommand::Toggle)).show(|ui| {
swatch_picker(app, ui, i); swatch_picker(app, ui, i);
}); });
if let AppearanceItem::Stroke(st) = it { if let AppearanceItem::Stroke(st) = it {
@ -269,67 +372,139 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(st.width), vectorcraft_doc::Unit::Points, 56.0) { if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(st.width), vectorcraft_doc::Unit::Points, 56.0) {
app.run("appearance.setItem", json!({"index": i, "weight": w})).ok(); app.run("appearance.setItem", json!({"index": i, "weight": w})).ok();
} }
stroke_notes(ui, Rect::from_min_max(pos2(fr.right() + 6.0, r.top()), pos2(r.right() - 4.0, r.bottom())), st);
} }
if resp.clicked() { if resp.clicked() {
select_row(app, ui.ctx(), Sel::Item(i)); let m = ui.input(|inp| inp.modifiers);
click_item(app, ui.ctx(), n, sel, i, m);
} }
if resp.double_clicked() { if resp.double_clicked() {
app.ui.open_panel = Some(if is_stroke { "stroke" } else { "color" }.into()); app.ui.open_panel = Some(if is_stroke { "stroke" } else { "color" }.into());
} }
if resp.drag_started() { track(&resp, Dragged::Item(i));
set_pstate(ui.ctx(), "ap-drag", Some(i));
}
if resp.drag_stopped() {
stopped = true;
}
if open { if open {
opacity_row(app, ui, Some(i), it.opacity(), it.blend()); let r = opacity_row(app, ui, Some(i), it.opacity(), it.blend());
rows.push((Slot::Item { index: i, sub: true }, r));
for (k, e) in it.effects().iter().enumerate() { for (k, e) in it.effects().iter().enumerate() {
effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k)); let (r, resp) = effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k));
rows.push((Slot::Effect(Some(i), k), r));
track(&resp, Dragged::Effect(Some(i), k));
} }
} }
} }
if stopped
&& let Some(from) = dragging
&& let Some(p) = ui.ctx().pointer_interact_pos()
{
drop = Some((from, target_index(&row_rects, p.y)));
}
if dragging.is_some() && !ui.ctx().input(|i| i.pointer.any_down()) {
set_pstate::<Option<usize>>(ui.ctx(), "ap-drag", None);
}
if let Some(p) = ui.ctx().pointer_interact_pos()
&& dragging.is_some()
{
// Insertion marker.
if let Some((_, r)) = row_rects.iter().find(|(_, r)| r.y_range().contains(p.y)) {
let y = if p.y < r.center().y { r.top() } else { r.bottom() };
ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent));
}
}
// Object-level effects. // Object-level effects.
for (k, e) in n.appearance.effects.iter().enumerate() { for (k, e) in n.appearance.effects.iter().enumerate() {
effect_row(app, ui, None, k, e, sel == Sel::Effect(k)); let (r, resp) = effect_row(app, ui, None, k, e, sel == Sel::Effect(k));
rows.push((Slot::Effect(None, k), r));
track(&resp, Dragged::Effect(None, k));
} }
opacity_row(app, ui, None, n.opacity, n.blend); let r = opacity_row(app, ui, None, n.opacity, n.blend);
if let Some((from, to_row)) = drop rows.push((Slot::Object { top: false }, r));
&& from != to_row let Some(what) = dragging else { return };
let pointer = ui.ctx().pointer_interact_pos();
if let Some(p) = pointer
&& let Some((slot, r)) = slot_at(&rows, p)
{ {
// The engine keeps the moved row active when it was. // Drop marker: a line where the row lands, or a frame round the row it goes into.
app.run("appearance.moveItem", json!({"from": from, "to": to_row})).ok(); let stroke = Stroke::new(2.0, t.accent);
let line = |y: f32| {
ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], stroke);
};
match (what, slot) {
(Dragged::Effect(..), Slot::Effect(..)) => line(if p.y < r.center().y { r.top() } else { r.bottom() }),
(Dragged::Effect(..), _) => {
ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside);
}
(Dragged::Item(from), _) => line(if item_at(n, slot) > from { r.top() } else { r.bottom() }),
}
}
if !ui.ctx().input(|i| i.pointer.any_down()) {
set_pstate::<Option<Dragged>>(ui.ctx(), "ap-drag", None);
}
if stopped
&& let Some(p) = pointer
&& let Some((cmd, params)) = drop_command(n, &rows, what, p, ui.input(|i| i.modifiers.alt))
&& let Ok(r) = app.run(cmd, params)
&& let Some(k) = r["index"].as_u64().filter(|_| cmd == "effect.move")
{
// The dropped effect becomes the selected row (a moved fill/stroke stays active in the engine).
let k = k as usize;
select_row(app, ui.ctx(), r["item"].as_u64().map_or(Sel::Effect(k), |i| Sel::ItemEffect(i as usize, k)));
} }
} }
/// Paint-order index a drop at `y` lands on (rows are listed top = last item). /// The row under `p`.
fn target_index(rows: &[(usize, Rect)], y: f32) -> usize { fn slot_at(rows: &[(Slot, Rect)], p: egui::Pos2) -> Option<(Slot, Rect)> {
rows.iter().find(|(_, r)| r.y_range().contains(y)).map(|(i, _)| *i).unwrap_or_else(|| { rows.iter().find(|(_, r)| r.contains(p)).copied()
if rows.first().is_some_and(|(_, r)| y < r.top()) { rows.first().map(|r| r.0).unwrap_or(0) } else { rows.last().map(|r| r.0).unwrap_or(0) } }
})
/// Paint-order index a dragged fill/stroke lands on when dropped on row `slot`: that row's item;
/// the object row stands above the topmost item, the rows below the items under item 0.
fn item_at(n: &Node, slot: Slot) -> usize {
match slot {
Slot::Item { index, .. } | Slot::Effect(Some(index), _) => index,
Slot::Object { top: true } => n.appearance.items.len().saturating_sub(1),
Slot::Object { top: false } | Slot::Effect(None, _) => 0,
}
}
/// The command a drop of `what` at `p` runs (`copy`: Alt held), or None when it would change
/// nothing or lands outside the stack.
fn drop_command(n: &Node, rows: &[(Slot, Rect)], what: Dragged, p: egui::Pos2, copy: bool) -> Option<(&'static str, Value)> {
let (slot, r) = slot_at(rows, p)?;
match what {
Dragged::Item(from) => {
let to = item_at(n, slot);
if copy {
// The copy goes where the row was dropped: above that row when dragged up, below it
// when dragged down.
let at = if to >= from { to + 1 } else { to };
Some(("appearance.duplicateItem", json!({"index": from, "to": at})))
} else {
(to != from).then(|| ("appearance.moveItem", json!({"from": from, "to": to})))
}
}
Dragged::Effect(from_item, k) => {
let (item, pos) = match slot {
Slot::Effect(item, k2) => (item, k2 + usize::from(p.y > r.center().y)),
Slot::Item { index, .. } => (Some(index), n.appearance.effects_at(Some(index))?.len()),
Slot::Object { .. } => (None, n.appearance.effects.len()),
};
// Within its own list the effect leaves its old place first.
let same = item == from_item && !copy;
let pos = if same && pos > k { pos - 1 } else { pos };
if same && pos == k {
return None;
}
Some(("effect.move", json!({"from": k, "fromItem": from_item, "to": pos, "toItem": item, "copy": copy})))
}
}
}
/// A stroke row's notes after the weight: its brush, "Dashed" and its width profile (drawn).
fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) {
let t = Tokens::get(ui.ctx());
let painter = ui.painter_at(r);
let dashed = st.dash.as_ref().is_some_and(|d| d.is_dashed()).then_some("Dashed");
let notes: Vec<&str> = st.brush.as_deref().into_iter().chain(dashed).collect();
let mut x = r.left();
if !notes.is_empty() {
let g = painter.layout_no_wrap(notes.join(", "), egui::FontId::proportional(11.5), t.text_dim);
x += g.size().x + 6.0;
painter.galley(pos2(r.left(), r.center().y - g.size().y / 2.0), g, t.text_dim);
}
if let Some(p) = st.profile.as_ref().filter(|p| p.preset_id() != Some("uniform")) {
let pr = Rect::from_min_size(pos2(x, r.center().y - 5.0), vec2(28.0, 10.0));
if pr.right() <= r.right() {
paint_profile(ui, pr, Some(p), t.text);
}
}
} }
/// The Opacity row of the object (`item: None`) or, indented, of fill/stroke `item`. Clicking it /// The Opacity row of the object (`item: None`) or, indented, of fill/stroke `item`. Clicking it
/// opens a popup with the shared opacity and blend controls (`transparency.set` on that item). /// opens a popup with the shared opacity and blend controls (`transparency.set` on that item).
fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opacity: f32, blend: BlendMode) { /// Returns the row's rect.
fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opacity: f32, blend: BlendMode) -> Rect {
let (r, resp) = row(ui, false); let (r, resp) = row(ui, false);
eye(ui, r, ("ap-op-eye", item), true, false); eye(ui, r, ("ap-op-eye", item), true, false);
let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 }; let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 };
@ -345,15 +520,19 @@ fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opaci
Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase), Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase),
None => {} None => {}
}); });
r
} }
/// An effect row: of the object (`item: None`) or, indented under it, of fill/stroke `item`. /// An effect row: of the object (`item: None`) or, indented under it, of fill/stroke `item`.
fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usize, e: &Effect, selected: bool) { /// Clicking the name opens the effect's dialog to edit it (the disclosure: an inline editor).
/// Returns the row's rect and response (for dragging).
fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usize, e: &Effect, selected: bool) -> (Rect, egui::Response) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
let open_key = format!("ap-fx-open-{item:?}-{k}"); let open_key = format!("ap-fx-open-{item:?}-{k}");
let open: bool = pstate(ui.ctx(), &open_key); let open: bool = pstate(ui.ctx(), &open_key);
let this = item.map_or(Sel::Effect(k), |i| Sel::ItemEffect(i, k)); let this = item.map_or(Sel::Effect(k), |i| Sel::ItemEffect(i, k));
let (r, resp) = row(ui, selected); let (row_rect, resp) = row(ui, selected);
let r = row_rect;
if eye(ui, r, ("ap-fx-eye", item, k), e.visible, true) { if eye(ui, r, ("ap-fx-eye", item, k), e.visible, true) {
app.run("effect.setParams", json!({"item": item, "index": k, "visible": !e.visible})).ok(); app.run("effect.setParams", json!({"item": item, "index": k, "visible": !e.visible})).ok();
} }
@ -364,8 +543,12 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usi
} }
let lx = r.left() + EYE_W + 24.0; let lx = r.left() + EYE_W + 24.0;
if link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &effect_label(&e.id)) { if link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &effect_label(&e.id)) {
set_pstate(ui.ctx(), &open_key, !open);
select_row(app, ui.ctx(), this); select_row(app, ui.ctx(), this);
if has_options(&e.id) {
app.run("effect.dialog", json!({"effect": e.id, "index": k, "item": item})).ok();
} else {
set_pstate(ui.ctx(), &open_key, !open);
}
} }
icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon); icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon);
if resp.clicked() { if resp.clicked() {
@ -377,6 +560,12 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usi
if open { if open {
effect_editor(app, ui, item, k, e); effect_editor(app, ui, item, k, e);
} }
(row_rect, resp)
}
/// Does the effect have a dialog (any parameters)?
fn has_options(id: &str) -> bool {
vectorcraft_effects::default_params(id).is_some_and(|d| d.as_object().is_some_and(|o| !o.is_empty()))
} }
/// Inline editor for an applied effect's parameters (numbers, booleans, strings, colours); /// Inline editor for an applied effect's parameters (numbers, booleans, strings, colours);
@ -497,31 +686,31 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel)
if widgets::icon_button_enabled(ui, "dc-clear", "Clear Appearance", false, has, 24.0).clicked() { if widgets::icon_button_enabled(ui, "dc-clear", "Clear Appearance", false, has, 24.0).clicked() {
app.run("appearance.clear", json!({})).ok(); app.run("appearance.clear", json!({})).ok();
} }
let can_dup = has && sel != Sel::None; let can = has && sel != Sel::None;
if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can_dup, 24.0).clicked() { if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can, 24.0).clicked() {
duplicate_selected(app, sel); duplicate_selected(app, ui, node, sel);
} }
let can_del = has && sel != Sel::None; if widgets::icon_button_enabled(ui, "trash-2", "Delete Selected Item", false, can, 24.0).clicked() {
if widgets::icon_button_enabled(ui, "trash-2", "Delete Selected Item", false, can_del, 24.0).clicked() { delete_selected(app, ui, node, sel);
delete_selected(app, ui, sel);
} }
let _ = basic; let _ = basic;
}); });
} }
fn duplicate_selected(app: &mut VectorcraftApp, sel: Sel) { /// Duplicate the selected fills/strokes (each copy right above its row) or the selected effect.
match (sel, sel.effect()) { fn duplicate_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) {
(Sel::Item(i), _) => app.run("appearance.duplicateItem", json!({"index": i})).ok(), match sel.effect() {
(_, Some(fx)) => app.run("effect.duplicate", fx).ok(), Some(fx) => app.run("effect.duplicate", fx).ok(),
_ => None, None if sel.item().is_some() => app.run("appearance.duplicateItem", json!({"indices": selected_items(ui.ctx(), node, sel)})).ok(),
None => None,
}; };
} }
fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) { fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) {
let ok = match (sel, sel.effect()) { let ok = match sel.effect() {
(Sel::Item(i), _) => app.run("appearance.removeItem", json!({"index": i})).is_ok(), Some(fx) => app.run("effect.remove", fx).is_ok(),
(_, Some(fx)) => app.run("effect.remove", fx).is_ok(), None if sel.item().is_some() => app.run("appearance.removeItem", json!({"indices": selected_items(ui.ctx(), node, sel)})).is_ok(),
_ => false, None => false,
}; };
// Removing the active item clears it in the engine; a removed item effect leaves its item // Removing the active item clears it in the engine; a removed item effect leaves its item
// selected. // selected.
@ -530,8 +719,9 @@ fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) {
} }
} }
/// The fx menu: effects grouped by their Effect-menu submenu; opens the effect dialog. /// The fx menu (Appearance panel, Properties): effects grouped by their Effect-menu submenu;
fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { /// opens the effect dialog.
pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let mut groups: Vec<(String, Vec<(String, String)>)> = vec![]; let mut groups: Vec<(String, Vec<(String, String)>)> = vec![];
for (id, label, menu) in catalog() { for (id, label, menu) in catalog() {
let g = menu.get(1).cloned().unwrap_or_else(|| "Other".into()); let g = menu.get(1).cloned().unwrap_or_else(|| "Other".into());
@ -564,10 +754,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
} }
ui.separator(); ui.separator();
if menu_item(ui, "Duplicate Item", has && sel != Sel::None, false) { if menu_item(ui, "Duplicate Item", has && sel != Sel::None, false) {
duplicate_selected(app, sel); duplicate_selected(app, ui, node.as_ref(), sel);
} }
if menu_item(ui, "Remove Item", has && sel != Sel::None, false) { if menu_item(ui, "Remove Item", has && sel != Sel::None, false) {
delete_selected(app, ui, sel); delete_selected(app, ui, node.as_ref(), sel);
} }
if menu_item(ui, "Clear Appearance", has, false) { if menu_item(ui, "Clear Appearance", has, false) {
app.run("appearance.clear", json!({})).ok(); app.run("appearance.clear", json!({})).ok();
@ -582,8 +772,14 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
set_pstate(ui.ctx(), "ap-hide-thumb", !hide); set_pstate(ui.ctx(), "ap-hide-thumb", !hide);
} }
ui.separator(); ui.separator();
menu_item(ui, "Redefine Graphic Style", false, false); let style = app.session.selection_graphic_style().map(|(g, _)| g.name.clone());
menu_item(ui, "Show All Hidden Attributes", false, false); let redefine = style.as_ref().map_or_else(|| "Redefine Graphic Style".into(), |n| format!("Redefine Graphic Style \u{201c}{n}\u{201d}"));
if menu_item(ui, &redefine, style.is_some(), false) {
app.run("graphicStyle.redefine", json!({})).ok();
}
if menu_item(ui, "Show All Hidden Attributes", node.as_ref().is_some_and(|n| n.appearance.has_hidden()), false) {
app.run("appearance.showAllHidden", json!({})).ok();
}
} }
#[cfg(test)] #[cfg(test)]
@ -606,11 +802,42 @@ mod tests {
} }
#[test] #[test]
fn drop_targets() { fn drops_pick_the_command() {
let r = |y: f32| Rect::from_min_size(pos2(0.0, y), vec2(100.0, ROW)); use vectorcraft_doc::{Appearance, FillLayer};
let rows = vec![(2, r(0.0)), (1, r(30.0)), (0, r(60.0))]; let fx = |id: &str| Effect { id: id.into(), params: Value::Null, visible: true };
assert_eq!(target_index(&rows, 45.0), 1); let mut n = Node::path(NodeId(1), vectorcraft_geom::PathData::default(), Appearance::default_art());
assert_eq!(target_index(&rows, -10.0), 2); n.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); // [F, S, F]
assert_eq!(target_index(&rows, 500.0), 0); n.appearance.items[1].effects_mut().extend([fx("distort.twist"), fx("distort.roughen")]);
n.appearance.effects.push(fx("stylize.dropShadow"));
let slots = [
Slot::Object { top: true },
Slot::Item { index: 2, sub: false },
Slot::Item { index: 1, sub: false },
Slot::Effect(Some(1), 0),
Slot::Effect(Some(1), 1),
Slot::Item { index: 0, sub: false },
Slot::Effect(None, 0),
Slot::Object { top: false },
];
let rows: Vec<(Slot, Rect)> =
slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect();
// The upper or lower half of row `i`.
let at = |i: usize, lower: bool| pos2(50.0, i as f32 * ROW + if lower { ROW * 0.75 } else { ROW * 0.25 });
let drop = |what, p, copy| drop_command(&n, &rows, what, p, copy);
// Fill/stroke rows: moves, Alt copies to where it was dropped.
assert_eq!(drop(Dragged::Item(0), at(1, false), false), Some(("appearance.moveItem", json!({"from": 0, "to": 2}))));
assert_eq!(drop(Dragged::Item(0), at(0, false), true), Some(("appearance.duplicateItem", json!({"index": 0, "to": 3}))));
assert_eq!(drop(Dragged::Item(2), at(7, false), true), Some(("appearance.duplicateItem", json!({"index": 2, "to": 0}))));
assert_eq!(drop(Dragged::Item(1), at(3, false), false), None, "onto its own effect");
// Effects: within the list (below the next one), into a fill, out onto the object.
let mv = |from: usize, fi: Value, to: usize, ti: Value, copy: bool| {
Some(("effect.move", json!({"from": from, "fromItem": fi, "to": to, "toItem": ti, "copy": copy})))
};
assert_eq!(drop(Dragged::Effect(Some(1), 0), at(4, true), false), mv(0, json!(1), 1, json!(1), false));
assert_eq!(drop(Dragged::Effect(Some(1), 0), at(3, true), false), None, "dropped on itself");
assert_eq!(drop(Dragged::Effect(Some(1), 1), at(5, false), true), mv(1, json!(1), 0, json!(0), true));
assert_eq!(drop(Dragged::Effect(Some(1), 1), at(7, false), false), mv(1, json!(1), 1, Value::Null, false));
assert_eq!(drop(Dragged::Effect(None, 0), at(3, false), false), mv(0, Value::Null, 0, json!(1), false));
assert_eq!(drop(Dragged::Effect(None, 0), pos2(500.0, 10.0), false), None, "outside the stack");
} }
} }

View File

@ -299,12 +299,12 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind:
if let Some(s) = stops.get_mut(*index) { if let Some(s) = stops.get_mut(*index) {
s.color = c; s.color = c;
} }
let params = json!({"stroke": !app.session.fill_active, "stops": super::gradient::stops_json(&stops)}); super::gradient::set_stops(app, &stops, None, phase);
live_run(app, "Gradient", "paint.editGradient", params, phase);
} }
_ => { _ => {
let cmd = super::proxy_cmd(app, behind); let cmd = super::proxy_cmd(app, behind);
live_run(app, "Color", cmd, json!({"color": color_json(&c), "focus": !behind}), phase); // The panel's sliders work in the model picked there, even in a CMYK document.
live_run(app, "Color", cmd, json!({"color": color_json(&c), "focus": !behind, "keepModel": true}), phase);
} }
} }
} }

View File

@ -1,5 +1,5 @@
//! Document Info panel: the document's setup and what it contains (objects by kind, fonts, //! Document Info panel: the document's setup and what it contains (objects by kind, fonts,
//! images, symbols, spot colours…), for the whole document or the selection only. //! images, symbols, spot colours, graphic styles…), for the whole document or the selection only.
use egui::Ui; use egui::Ui;
use serde_json::{Value, json}; use serde_json::{Value, json};
@ -71,7 +71,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::dim_label(ui, "None"); widgets::dim_label(ui, "None");
} }
widgets::divider(ui); widgets::divider(ui);
for (key, title) in [("fonts", "Fonts"), ("symbols", "Symbols"), ("spotColors", "Spot Colors")] { for (key, title) in [("fonts", "Fonts"), ("symbols", "Symbols"), ("spotColors", "Spot Colors"), ("graphicStyleNames", "Graphic Styles")] {
let list: Vec<&str> = i[key].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); let list: Vec<&str> = i[key].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
widgets::subheader(ui, &format!("{title} ({})", list.len())); widgets::subheader(ui, &format!("{title} ({})", list.len()));
for f in list { for f in list {
@ -87,7 +87,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::divider(ui); widgets::divider(ui);
for (key, label) in [ for (key, label) in [
("swatches", "Swatches"), ("swatches", "Swatches"),
("graphicStyles", "Graphic Styles"),
("characterStyles", "Character Styles"), ("characterStyles", "Character Styles"),
("paragraphStyles", "Paragraph Styles"), ("paragraphStyles", "Paragraph Styles"),
("patterns", "Pattern Swatches"), ("patterns", "Pattern Swatches"),

View File

@ -1,23 +1,41 @@
//! Gradient panel: type buttons, angle / aspect ratio / reverse, the gradient slider with draggable //! Gradient panel: the gradient thumbnail (drag it onto art; its menu lists the document's
//! stops and midpoint diamonds (click below the ramp adds a stop, drag a stop off to remove it), //! gradient swatches and Save to Swatches), type buttons, the Fill/Stroke proxy, angle and aspect
//! and stop Opacity / Location fields. Stop colours are edited with the Color panel. //! ratio fields with preset menus, Reverse, the gradient slider and the selected stop's fields.
//!
//! On the slider: click below the ramp to add a stop, drag a stop to move it (Alt drags a copy;
//! Alt-dropping it on another stop swaps their colours), drag it off to remove it, double-click it
//! for the stop popover, and drag a diamond to move a midpoint (a selected diamond's midpoint
//! shows in Location). Dropping a colour swatch on the ramp adds a stop of that colour, or
//! recolours the stop it lands on. Hide Options leaves the thumbnail, the proxy and the slider.
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::gradient::{MIN_STOPS, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint}; use vectorcraft_color::gradient::{
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop, Paint}; MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors,
};
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::NodeId;
use super::{active_paint, live_run, pstate, set_pstate}; use super::{active_paint, live_run, pstate, set_pstate};
use crate::state::Dialog;
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::widgets::{self, Live, menu_item}; use crate::widgets::{self, Live, PanelDrag, menu_item};
use crate::{VectorcraftApp, icons}; use crate::{VectorcraftApp, icons};
/// Dragging a stop this far below the ramp removes it. /// Dragging a stop this far below the ramp removes it.
pub const REMOVE_DISTANCE: f32 = 28.0; const REMOVE_DISTANCE: f32 = 28.0;
/// The Angle field's preset menu, degrees.
const ANGLE_PRESETS: [f64; 9] = [-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0, 135.0, 180.0];
/// The Aspect Ratio field's preset menu, percent.
const ASPECT_PRESETS: [f64; 9] = [10.0, 25.0, 50.0, 75.0, 100.0, 150.0, 200.0, 300.0, 400.0];
/// Panel-state key of Show/Hide Options.
const HIDE_OPTIONS: &str = "gradient-hide-options";
/// Panel-state key of the selected midpoint diamond.
const MID_SELECTED: &str = "grad-mid";
/// Select stop `i` (the session's selected stop, shared with the Gradient tool's annotator, the /// Select stop `i` (the session's selected stop, shared with the Gradient tool's annotator, the
/// Color panel and agents). Run after the edit that creates it. /// Color panel and agents), or none.
fn select_stop(app: &mut VectorcraftApp, i: usize) { fn select_stop(app: &mut VectorcraftApp, i: Option<usize>) {
app.run("gradient.selectStop", json!({ "index": i })).ok(); app.run("gradient.selectStop", json!({ "index": i })).ok();
} }
@ -31,8 +49,32 @@ pub fn x_to_offset(x: f32, left: f32, width: f32) -> f32 {
/// Stops as `paint.editGradient` JSON. /// Stops as `paint.editGradient` JSON.
pub use vectorcraft_tools::params::stops_json; pub use vectorcraft_tools::params::stops_json;
// ---------- UI ---------- /// A colour dropped on the ramp: recolours stop `on` (fully opaque), or adds an opaque stop of
/// that colour at `offset`. Returns the stops and the index of the stop that took the colour.
fn drop_color(g: &Gradient, on: Option<usize>, offset: f32, c: Color) -> (Vec<GradientStop>, usize) {
let (mut v, i) = match on.filter(|i| *i < g.stops.len()) {
Some(i) => (g.stops.clone(), i),
None => insert_stop(g, offset),
};
v[i].color = c;
v[i].opacity = 1.0;
(v, i)
}
/// The stops after dragging stop `i` of `origin` to `offset`: moved, or with `copy` (Alt) a copy
/// left there, or the colours swapped with stop `over` when the copy is dropped on it. Returns
/// the stops and the stop to select.
fn dragged_stops(origin: &[GradientStop], i: usize, offset: f32, copy: bool, over: Option<usize>) -> (Vec<GradientStop>, usize) {
match (copy, over.filter(|j| *j != i)) {
(true, Some(j)) => (swap_stop_colors(origin, i, j), i),
(true, None) => duplicate_stop(origin, i, offset),
(false, _) => move_stop(origin, i, offset),
}
}
// ---------- shared edits ----------
/// The gradient behind the active proxy.
fn current(app: &VectorcraftApp) -> Option<GradientPaint> { fn current(app: &VectorcraftApp) -> Option<GradientPaint> {
match active_paint(app) { match active_paint(app) {
Paint::Gradient(g) => Some(*g), Paint::Gradient(g) => Some(*g),
@ -40,36 +82,86 @@ fn current(app: &VectorcraftApp) -> Option<GradientPaint> {
} }
} }
/// Edit the gradient behind the active proxy (`paint.editGradient` params).
fn edit(app: &mut VectorcraftApp, params: Value, phase: Live) { fn edit(app: &mut VectorcraftApp, params: Value, phase: Live) {
let mut p = params; let mut p = params;
p["stroke"] = json!(!app.session.fill_active); p["stroke"] = json!(!app.session.fill_active);
live_run(app, "Gradient", "paint.editGradient", p, phase); live_run(app, "Gradient", "paint.editGradient", p, phase);
} }
/// Write `stops` to the gradient behind the active proxy (live while dragging) and, once
/// released, select stop `select` (the panel, the stop popover and the Color panel share this).
pub(crate) fn set_stops(app: &mut VectorcraftApp, stops: &[GradientStop], select: Option<usize>, phase: Live) {
edit(app, json!({ "stops": stops_json(stops) }), phase);
if phase == Live::Released
&& let Some(i) = select
&& app.session.selected_stop() != Some(i)
{
select_stop(app, Some(i));
}
}
/// Save the gradient as a swatch.
fn save_to_swatches(app: &mut VectorcraftApp, g: &GradientPaint) {
app.run("swatch.new", super::paint_params(&Paint::Gradient(Box::new(g.clone())))).ok();
}
/// The document's gradient swatches (groups included): name and gradient.
fn gradient_swatches(app: &VectorcraftApp) -> Vec<(String, Gradient)> {
let Some(st) = app.session.active() else { return vec![] };
let d = &st.doc;
d.swatches
.iter()
.chain(d.swatch_groups.iter().flat_map(|g| g.swatches.iter()))
.filter_map(|s| match &s.paint {
Paint::Gradient(g) => Some((s.name.clone(), g.gradient.clone())),
_ => None,
})
.collect()
}
/// The selected midpoint diamond: kept for the object it was picked on, and given up when a stop
/// is selected.
fn selected_mid(app: &VectorcraftApp, ctx: &egui::Context, stops: usize) -> Option<usize> {
let (i, owner) = pstate::<Option<(usize, Option<NodeId>)>>(ctx, MID_SELECTED)?;
(app.session.selected_stop().is_none() && i + 1 < stops && owner == first_id(app)).then_some(i)
}
fn first_id(app: &VectorcraftApp) -> Option<NodeId> {
app.session.active().and_then(|d| d.selection.objects.first().copied())
}
// ---------- UI ----------
/// Drag state of the ramp: which handle is being dragged. /// Drag state of the ramp: which handle is being dragged.
#[derive(Clone, Copy, Debug, Default, PartialEq)] #[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Drag { enum Drag {
#[default] #[default]
None, None,
Stop(usize), /// Stop `index`; `copy` (Alt held when the drag began) drags a copy.
Stop {
index: usize,
copy: bool,
},
Mid(usize), Mid(usize),
} }
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
let gp = current(app); let gp = current(app);
let fallback = GradientPaint::new(Gradient::default());
let g = gp.clone().unwrap_or(fallback);
let is_grad = gp.is_some(); let is_grad = gp.is_some();
let g = gp.unwrap_or_else(|| GradientPaint::new(Gradient::default()));
let kind = g.gradient.kind; let kind = g.gradient.kind;
// Header: gradient swatch + type buttons + Edit Gradient. let hidden: bool = pstate(ui.ctx(), HIDE_OPTIONS);
// Header: thumbnail + its swatch menu, the type buttons and Edit Gradient.
ui.horizontal(|ui| { ui.horizontal(|ui| {
let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click()); thumbnail(app, ui, &g, is_grad);
widgets::paint_chip(ui, r, &Paint::Gradient(Box::new(g.clone()))); if hidden {
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); ui.add_space(6.0);
if resp.on_hover_text("Gradient Fill — click to apply").clicked() && !is_grad { super::proxy(app, ui, 36.0);
edit(app, json!({}), Live::Released); return;
} }
ui.add_space(4.0);
ui.vertical(|ui| { ui.vertical(|ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
widgets::dim_label(ui, "Type:"); widgets::dim_label(ui, "Type:");
@ -83,13 +175,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} }
} }
}); });
ui.horizontal(|ui| { if widgets::flat_button(ui, "Edit Gradient", 96.0).clicked() {
if widgets::flat_button(ui, "Edit Gradient", 96.0).clicked() { app.select_tool("gradient");
app.select_tool("gradient"); }
}
});
}); });
}); });
if hidden {
ui.add_space(6.0);
ramp(app, ui, &g.gradient, is_grad);
return;
}
ui.add_space(4.0); ui.add_space(4.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
widgets::dim_label(ui, "Stroke:"); widgets::dim_label(ui, "Stroke:");
@ -101,87 +196,170 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::icon_button_enabled(ui, icon, tip, icon == "dc-stroke-center" && !app.session.fill_active, false, 22.0); widgets::icon_button_enabled(ui, icon, tip, icon == "dc-stroke-center" && !app.session.fill_active, false, 22.0);
} }
}); });
// The proxy beside the angle and aspect ratio fields.
ui.horizontal(|ui| { ui.horizontal(|ui| {
icons::icon(ui, "rotate-ccw", 15.0, t.icon).on_hover_text("Angle"); super::proxy(app, ui, 36.0);
if let Some(a) = widgets::plain_field(ui, "grad-angle", g.geom.map(|x| x.angle_deg()).unwrap_or(g.angle), "°", 1, 64.0) {
edit(app, json!({"angle": a}), Live::Released);
}
ui.add_space(4.0); ui.add_space(4.0);
let radial = kind == GradientKind::Radial; ui.vertical(|ui| {
icons::icon(ui, "scaling", 15.0, if radial { t.icon } else { t.text_disabled }).on_hover_text("Aspect Ratio"); ui.horizontal(|ui| {
let asp = g.geom.map(|x| x.aspect * 100.0).unwrap_or(100.0); icons::icon(ui, "rotate-ccw", 15.0, t.icon).on_hover_text("Angle");
if radial { let angle = g.geom.map_or(g.angle, |x| x.angle_deg());
if let Some(a) = widgets::plain_field(ui, "grad-aspect", asp, "%", 1, 60.0) { if let Some(a) = widgets::spin_plain(ui, "grad-angle", (angle * 10.0).round() / 10.0, "°", 1, 104.0, 1.0, -180.0, &ANGLE_PRESETS) {
edit(app, json!({"aspect": a}), Live::Released); edit(app, json!({"angle": a}), Live::Released);
} }
} else { if widgets::icon_button_enabled(ui, "dc-reverse", "Reverse Gradient", false, is_grad, 22.0).clicked() {
ui.add_enabled(false, egui::Label::new(egui::RichText::new(format!("{asp:.0}%")).color(t.text_disabled))); edit(app, json!({"reverse": true}), Live::Released);
} }
if widgets::icon_button_enabled(ui, "dc-reverse", "Reverse Gradient", false, is_grad, 22.0).clicked() { });
edit(app, json!({"reverse": true}), Live::Released); let radial = kind == GradientKind::Radial;
} ui.horizontal(|ui| {
icons::icon(ui, "scaling", 15.0, if radial { t.icon } else { t.text_disabled }).on_hover_text("Aspect Ratio");
let asp = g.geom.map_or(100.0, |x| (x.aspect * 1000.0).round() / 10.0);
let set = ui.add_enabled_ui(radial, |ui| widgets::spin_plain(ui, "grad-aspect", asp, "%", 1, 104.0, 1.0, 0.5, &ASPECT_PRESETS)).inner;
if let Some(a) = set {
edit(app, json!({"aspect": a}), Live::Released);
}
});
});
}); });
ui.add_space(6.0); ui.add_space(6.0);
ramp(app, ui, &g.gradient, is_grad); ramp(app, ui, &g.gradient, is_grad);
ui.add_space(4.0); ui.add_space(4.0);
// Stop fields. stop_fields(app, ui, &g.gradient, is_grad);
let sel = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len());
ui.horizontal(|ui| {
let enabled = is_grad && sel.is_some();
let stop = sel.and_then(|i| g.gradient.stops.get(i)).copied();
widgets::dim_label(ui, "Opacity:");
let op = stop.map(|s| s.opacity as f64 * 100.0).unwrap_or(100.0);
if let Some(v) = widgets::plain_field(ui, "grad-op", op, "%", 0, 54.0)
&& enabled
&& let Some(i) = sel
{
let mut stops = g.gradient.stops.clone();
stops[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32;
edit(app, json!({"stops": stops_json(&stops)}), Live::Released);
}
widgets::dim_label(ui, "Location:");
let loc = stop.map(|s| s.offset as f64 * 100.0).unwrap_or(0.0);
if let Some(v) = widgets::plain_field(ui, "grad-loc", loc, "%", 1, 54.0)
&& enabled
&& let Some(i) = sel
{
let (stops, ni) = move_stop(&g.gradient.stops, i, (v / 100.0) as f32);
edit(app, json!({"stops": stops_json(&stops)}), Live::Released);
select_stop(app, ni);
}
let can_del = enabled && g.gradient.stops.len() > MIN_STOPS;
if widgets::icon_button_enabled(ui, "trash-2", "Delete Stop", false, can_del, 22.0).clicked()
&& let Some(i) = sel
&& let Some(stops) = remove_stop(&g.gradient.stops, i)
{
edit(app, json!({"stops": stops_json(&stops)}), Live::Released);
select_stop(app, i.min(stops.len() - 1));
}
});
if let Some(i) = sel
&& is_grad
{
let c = g.gradient.stops[i].color;
ui.horizontal(|ui| {
widgets::dim_label(ui, "Stop color:");
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
widgets::swatch_tile(ui, r, &Paint::solid(c), false, resp.hovered());
widgets::dim_label(ui, &c.to_hex().to_uppercase());
if resp.on_hover_text("Edit the stop in the Color panel").clicked() {
app.ui.open_panel = Some("color".into());
}
});
}
if !is_grad { if !is_grad {
widgets::dim_label(ui, "Click the ramp or a type button to apply a gradient."); widgets::dim_label(ui, "Click the ramp or a type button to apply a gradient.");
} }
} }
/// The gradient thumbnail (click: apply a gradient; drag: onto art) and its menu of gradient
/// swatches with Save to Swatches.
fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click_and_drag());
widgets::gradient_chip(ui, r, &g.gradient);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
let resp = resp.on_hover_text("Gradient Fill: click to apply, drag onto art");
if resp.clicked() && !is_grad {
edit(app, json!({}), Live::Released);
}
// The dragged copy carries no placement: it fits the art it lands on.
widgets::drag_source(ui, &resp, || PanelDrag::paint(Paint::Gradient(Box::new(g.clone()))));
let dr = Rect::from_min_size(r.right_top() + vec2(1.0, 0.0), vec2(14.0, 40.0));
ui.advance_cursor_after_rect(dr);
let dresp = ui.interact(dr, ui.id().with("grad-swatches"), Sense::click());
icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), vec2(12.0, 12.0)), if dresp.hovered() { t.text_strong } else { t.icon });
let dresp = dresp.on_hover_text("Gradient swatches");
egui::Popup::menu(&dresp).show(|ui| {
ui.set_min_width(200.0);
let mut chosen = None;
let swatches = gradient_swatches(app);
if swatches.is_empty() {
widgets::dim_label(ui, "No gradient swatches");
}
for (name, grad) in &swatches {
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
let chip = Rect::from_min_size(row.left_center() + vec2(4.0, -8.0), vec2(16.0, 16.0));
widgets::gradient_chip(ui, chip, grad);
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
ui.painter().text(chip.right_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text);
if resp.clicked() {
chosen = Some(name.clone());
}
}
ui.separator();
if menu_item(ui, "Save to Swatches", is_grad, false) {
save_to_swatches(app, g);
}
if let Some(name) = chosen {
super::apply_click(app, ui, json!({ "swatch": name }));
ui.close();
}
});
}
/// The selected stop's colour (click: its popover; the eyedropper samples one from the art),
/// opacity and location (a selected diamond's midpoint instead), and Delete Stop.
fn stop_fields(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
let stops = &g.stops;
let sel = app.session.selected_stop().filter(|i| *i < stops.len() && is_grad);
let mid = selected_mid(app, ui.ctx(), stops.len()).filter(|_| is_grad);
let stop = sel.map(|i| stops[i]);
if let (Some(i), Some(s)) = (sel, stop) {
ui.horizontal(|ui| {
widgets::dim_label(ui, "Color:");
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
widgets::swatch_tile(ui, r, &Paint::solid(s.color), false, resp.hovered());
widgets::dim_label(ui, &s.color.to_hex().to_uppercase());
if resp.on_hover_text("Edit the stop").clicked() {
open_popover(app, i, r.left_bottom() + vec2(0.0, 4.0));
}
ui.add_space(ui.available_width() - 22.0);
if widgets::icon_button(ui, "pipette", "Eyedropper: click the art to sample the stop's colour", false, 22.0).clicked() {
stop_eyedropper(app);
}
});
}
ui.horizontal(|ui| {
ui.add_enabled_ui(sel.is_some(), |ui| {
widgets::dim_label(ui, "Opacity:");
if let Some(v) = widgets::plain_field(ui, "grad-op", stop.map_or(100.0, |s| s.opacity as f64 * 100.0), "%", 0, 54.0)
&& let Some(i) = sel
{
let mut v2 = stops.clone();
v2[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32;
set_stops(app, &v2, Some(i), Live::Released);
}
});
ui.add_enabled_ui(sel.is_some() || mid.is_some(), |ui| {
widgets::dim_label(ui, "Location:");
let loc = match (mid, stop) {
(Some(m), _) => stops[m].midpoint as f64 * 100.0,
(None, Some(s)) => s.offset as f64 * 100.0,
_ => 0.0,
};
if let Some(v) = widgets::plain_field(ui, ("grad-loc", mid.is_some()), loc, "%", 1, 54.0) {
let v = (v / 100.0) as f32;
if let Some(m) = mid {
set_stops(app, &set_midpoint(stops, m, v), None, Live::Released);
} else if let Some(i) = sel {
let (v2, ni) = move_stop(stops, i, v);
set_stops(app, &v2, Some(ni), Live::Released);
}
}
});
let can_del = sel.is_some() && stops.len() > MIN_STOPS;
if widgets::icon_button_enabled(ui, "trash-2", "Delete Stop", false, can_del, 22.0).clicked()
&& let Some(i) = sel
&& let Some(v) = remove_stop(stops, i)
{
let next = i.min(v.len() - 1);
set_stops(app, &v, Some(next), Live::Released);
}
});
}
/// The stop's eyedropper: the Eyedropper tool samples the next colour clicked on the art into the
/// selected stop, then hands back to the current tool.
fn stop_eyedropper(app: &mut VectorcraftApp) {
let back = app.session.tool_id();
app.select_tool("eyedropper");
app.session.set_tool_option("stop", &json!(back));
}
/// Open the stop popover for stop `i` at screen position `at`.
fn open_popover(app: &mut VectorcraftApp, i: usize, at: Pos2) {
select_stop(app, Some(i));
app.ui.dialog = Some(Dialog::new("gradientStop", json!({ "index": i, "screen": [at.x, at.y], "tab": "color" })));
}
/// The gradient slider: ramp, stops below, midpoint diamonds above. /// The gradient slider: ramp, stops below, midpoint diamonds above.
fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
let w = ui.available_width(); let w = ui.available_width();
let (area, _) = ui.allocate_exact_size(vec2(w, 50.0), Sense::hover()); let (area, area_resp) = ui.allocate_exact_size(vec2(w, 50.0), Sense::hover());
let bar = Rect::from_min_size(area.min + vec2(8.0, 10.0), vec2(w - 16.0, 18.0)); let bar = Rect::from_min_size(area.min + vec2(8.0, 10.0), vec2(w - 16.0, 18.0));
// Checkerboard under the ramp (opacity). // Checkerboard under the ramp (opacity).
let cell = 6.0; let cell = 6.0;
@ -208,37 +386,67 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
Rect::from_min_size(pos2(bar.left() + i as f32 * bar.width() / n as f32, bar.top()), vec2(bar.width() / n as f32 + 0.5, bar.height())); Rect::from_min_size(pos2(bar.left() + i as f32 * bar.width() / n as f32, bar.top()), vec2(bar.width() / n as f32 + 0.5, bar.height()));
ui.painter().rect_filled(rr, 0.0, Color32::from_rgba_unmultiplied(r, gg, b, a)); ui.painter().rect_filled(rr, 0.0, Color32::from_rgba_unmultiplied(r, gg, b, a));
} }
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); // A colour swatch held over the ramp: outline it as a drop target.
let droppable = is_grad && area_resp.dnd_hover_payload::<PanelDrag>().is_some_and(|d| d.color().is_some());
let edge = if droppable { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) };
ui.painter().rect_stroke(bar, 0.0, edge, StrokeKind::Outside);
let x_of = |o: f32| bar.left() + o * bar.width(); let x_of = |o: f32| bar.left() + o * bar.width();
let offset_at = |p: Pos2| x_to_offset(p.x, bar.left(), bar.width());
let marker = |o: f32| Rect::from_min_size(pos2(x_of(o) - 6.0, bar.bottom() + 2.0), vec2(12.0, 16.0));
let sel = app.session.selected_stop(); let sel = app.session.selected_stop();
let mut drag: Drag = pstate(ui.ctx(), "grad-drag");
let stops = &g.stops; let stops = &g.stops;
let mid_sel = selected_mid(app, ui.ctx(), stops.len());
let mut drag: Drag = pstate(ui.ctx(), "grad-drag");
// While dragging, edits are computed against the stops as they were when the drag began (the // While dragging, edits are computed against the stops as they were when the drag began (the
// document shows the preview). // document shows the preview).
let origin: Vec<GradientStop> = if drag == Drag::None { stops.clone() } else { pstate::<Vec<GradientStop>>(ui.ctx(), "grad-origin") }; let origin: Vec<GradientStop> = if drag == Drag::None { stops.clone() } else { pstate::<Vec<GradientStop>>(ui.ctx(), "grad-origin") };
let origin = if origin.len() == stops.len() { origin } else { stops.clone() }; let origin = if origin.is_empty() { stops.clone() } else { origin };
// The stop of `origin` other than `i` whose marker is under `p`.
let stop_under = |p: Pos2, i: usize| origin.iter().enumerate().position(|(j, s)| j != i && marker(s.offset).contains(p));
let mut changed: Option<(Vec<GradientStop>, Live)> = None; let mut changed: Option<(Vec<GradientStop>, Live)> = None;
// The stop to select once `changed` is applied. // The stop to select once `changed` is applied.
let mut select: Option<usize> = None; let mut select: Option<usize> = None;
let mut pick_mid: Option<usize> = None;
let mut popover: Option<(usize, Pos2)> = None;
// The ramp and the strip below it, under the stops and diamonds (interacted first): a click
// below adds a stop, and without a gradient either applies one.
let below = Rect::from_min_max(pos2(bar.left(), bar.bottom()), pos2(bar.right(), area.bottom()));
let add_resp = ui.interact(below, ui.id().with("grad-add"), Sense::click());
let bar_resp = ui.interact(bar, ui.id().with("grad-bar"), Sense::click());
if add_resp.hovered() && is_grad {
ui.ctx().set_cursor_icon(egui::CursorIcon::Copy);
}
if !is_grad && (bar_resp.clicked() || add_resp.clicked()) {
edit(app, json!({}), Live::Released);
} else if add_resp.clicked()
&& let Some(p) = add_resp.interact_pointer_pos()
{
let (v, i) = insert_stop(g, offset_at(p));
select = Some(i);
changed = Some((v, Live::Released));
}
// Midpoint diamonds. // Midpoint diamonds.
for i in 0..stops.len().saturating_sub(1) { for i in 0..stops.len().saturating_sub(1) {
let Some(p) = midpoint_pos(stops, i) else { continue }; let Some(p) = midpoint_pos(stops, i) else { continue };
let c = pos2(x_of(p), bar.top() - 5.0); let c = pos2(x_of(p), bar.top() - 5.0);
let rect = Rect::from_center_size(c, vec2(10.0, 10.0)); let rect = Rect::from_center_size(c, vec2(10.0, 10.0));
let resp = ui.interact(rect, ui.id().with(("grad-mid", i)), Sense::click_and_drag()); let resp = ui.interact(rect, ui.id().with(("grad-mid", i)), Sense::click_and_drag());
let active = drag == Drag::Mid(i) || resp.hovered(); let lit = drag == Drag::Mid(i) || mid_sel == Some(i) || resp.hovered();
let pts = vec![c + vec2(0.0, -4.0), c + vec2(4.0, 0.0), c + vec2(0.0, 4.0), c + vec2(-4.0, 0.0)]; let pts = vec![c + vec2(0.0, -4.0), c + vec2(4.0, 0.0), c + vec2(0.0, 4.0), c + vec2(-4.0, 0.0)];
ui.painter().add(egui::Shape::convex_polygon(pts, if active { Color32::WHITE } else { t.icon }, Stroke::new(1.0, t.border))); ui.painter().add(egui::Shape::convex_polygon(pts, if lit { Color32::WHITE } else { t.icon }, Stroke::new(1.0, t.border)));
if !is_grad { if !is_grad {
continue; continue;
} }
if resp.clicked() || resp.drag_started() {
pick_mid = Some(i);
}
if resp.drag_started() { if resp.drag_started() {
drag = Drag::Mid(i); drag = Drag::Mid(i);
set_pstate(ui.ctx(), "grad-origin", stops.clone()); set_pstate(ui.ctx(), "grad-origin", stops.clone());
} }
if (resp.dragged() || resp.drag_stopped()) if (resp.dragged() || resp.drag_stopped())
&& let Some(pp) = resp.interact_pointer_pos() && let Some(pp) = resp.interact_pointer_pos()
&& let Some(m) = midpoint_from_pos(&origin, i, x_to_offset(pp.x, bar.left(), bar.width())) && let Some(m) = midpoint_from_pos(&origin, i, offset_at(pp))
{ {
let phase = if resp.drag_stopped() { Live::Released } else { Live::Dragging }; let phase = if resp.drag_stopped() { Live::Released } else { Live::Dragging };
changed = Some((set_midpoint(&origin, i, m), phase)); changed = Some((set_midpoint(&origin, i, m), phase));
@ -248,15 +456,19 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
} }
} }
// Stops (house-shaped markers under the ramp). // Stops (house-shaped markers under the ramp).
let mut hit_stop = false; let alt = super::alt_held(ui);
for (i, s) in stops.iter().enumerate() { for (i, s) in stops.iter().enumerate() {
let x = x_of(s.offset); let x = x_of(s.offset);
let top = bar.bottom() + 2.0; let m = marker(s.offset);
let marker = Rect::from_min_size(pos2(x - 6.0, top), vec2(12.0, 16.0)); let top = m.top();
let resp = ui.interact(marker, ui.id().with(("grad-stop", i)), Sense::click_and_drag()); let resp = ui.interact(m, ui.id().with(("grad-stop", i)), Sense::click_and_drag());
hit_stop |= resp.hovered() || resp.dragged(); let (dragging_this, copy) = match drag {
let dragging_this = drag == Drag::Stop(i); Drag::Stop { index, copy } => (index == i, copy),
let off = dragging_this && resp.interact_pointer_pos().is_some_and(|p| p.y > bar.bottom() + REMOVE_DISTANCE) && origin.len() > MIN_STOPS; _ => (false, false),
};
// A moved stop (not a copy) dragged this far below the ramp is removed on release.
let off_ramp = |p: Pos2| !copy && p.y > bar.bottom() + REMOVE_DISTANCE && origin.len() > MIN_STOPS;
let off = dragging_this && resp.interact_pointer_pos().is_some_and(off_ramp);
let outline = if sel == Some(i) { t.accent } else { t.border }; let outline = if sel == Some(i) { t.accent } else { t.border };
let body = vec![pos2(x, top), pos2(x + 6.0, top + 5.0), pos2(x + 6.0, top + 15.0), pos2(x - 6.0, top + 15.0), pos2(x - 6.0, top + 5.0)]; let body = vec![pos2(x, top), pos2(x + 6.0, top + 5.0), pos2(x + 6.0, top + 15.0), pos2(x - 6.0, top + 15.0), pos2(x - 6.0, top + 5.0)];
if !off { if !off {
@ -267,89 +479,97 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
if !is_grad { if !is_grad {
continue; continue;
} }
if resp.double_clicked() {
popover = Some((i, m.left_bottom() + vec2(0.0, 4.0)));
}
if resp.clicked() || resp.drag_started() { if resp.clicked() || resp.drag_started() {
select = Some(i); select = Some(i);
} }
if resp.drag_started() { if resp.drag_started() {
drag = Drag::Stop(i); drag = Drag::Stop { index: i, copy: alt };
set_pstate(ui.ctx(), "grad-origin", stops.clone()); set_pstate(ui.ctx(), "grad-origin", stops.clone());
} }
if (resp.dragged() || resp.drag_stopped()) if (resp.dragged() || resp.drag_stopped())
&& dragging_this && dragging_this
&& let Some(pp) = resp.interact_pointer_pos() && let Some(pp) = resp.interact_pointer_pos()
{ {
let removing = pp.y > bar.bottom() + REMOVE_DISTANCE && origin.len() > MIN_STOPS; let phase = if resp.drag_stopped() { Live::Released } else { Live::Dragging };
if resp.drag_stopped() { if resp.drag_stopped() {
drag = Drag::None; drag = Drag::None;
if removing { }
if let Some(v) = remove_stop(&origin, i) { if !off_ramp(pp) {
select = Some(i.min(v.len() - 1)); let over = if copy { stop_under(pp, i) } else { None };
changed = Some((v, Live::Released)); let (v, ni) = dragged_stops(&origin, i, offset_at(pp), copy, over);
} select = Some(ni);
} else { changed = Some((v, phase));
let (v, ni) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width())); } else if phase == Live::Released {
select = Some(ni); if let Some(v) = remove_stop(&origin, i) {
changed = Some((v, Live::Released)); select = Some(i.min(v.len() - 1));
changed = Some((v, phase));
} }
} else if removing {
// Dragged off: show the gradient without moving the stop until release.
changed = Some((origin.clone(), Live::Dragging));
} else { } else {
let (v, _) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width())); // Dragged off: show the gradient without the stop moving until release.
changed = Some((v, Live::Dragging)); changed = Some((origin.clone(), phase));
} }
} }
} }
// Click below the ramp (not on a stop) adds a stop; clicking the ramp applies a gradient. // A colour swatch dropped on the ramp.
let below = Rect::from_min_max(pos2(bar.left(), bar.bottom()), pos2(bar.right(), area.bottom())); if is_grad
let resp = ui.interact(below, ui.id().with("grad-add"), Sense::click()); && let Some(d) = area_resp.dnd_release_payload::<PanelDrag>()
if resp.hovered() && !hit_stop && is_grad { && let Some(c) = d.color()
ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); && let Some(p) = ui.input(|i| i.pointer.latest_pos())
}
if resp.clicked()
&& !hit_stop
&& let Some(p) = resp.interact_pointer_pos()
{ {
if is_grad { let (v, i) = drop_color(g, stops.iter().position(|s| marker(s.offset).contains(p)), offset_at(p), c);
let (v, i) = insert_stop(g, x_to_offset(p.x, bar.left(), bar.width())); select = Some(i);
select = Some(i); changed = Some((v, Live::Released));
changed = Some((v, Live::Released));
} else {
edit(app, json!({}), Live::Released);
}
}
let bar_resp = ui.interact(bar, ui.id().with("grad-bar"), Sense::click());
if bar_resp.clicked() && !is_grad {
edit(app, json!({}), Live::Released);
} }
set_pstate(ui.ctx(), "grad-drag", drag); set_pstate(ui.ctx(), "grad-drag", drag);
if let Some((v, phase)) = changed { if let Some(i) = pick_mid {
edit(app, json!({"stops": stops_json(&v)}), phase); set_pstate(ui.ctx(), MID_SELECTED, Some((i, first_id(app))));
if sel.is_some() {
select_stop(app, None);
}
} else if select.is_some() {
set_pstate(ui.ctx(), MID_SELECTED, None::<(usize, Option<NodeId>)>);
} }
if let Some(i) = select { match changed {
select_stop(app, i); Some((v, phase)) => set_stops(app, &v, select, phase),
None => {
if let Some(i) = select.filter(|i| sel != Some(*i)) {
select_stop(app, Some(i));
}
}
}
if let Some((i, at)) = popover {
open_popover(app, i, at);
} }
} }
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let g = current(app); let g = current(app);
menu_item(ui, "Hide Options", false, false); let hidden: bool = pstate(ui.ctx(), HIDE_OPTIONS);
if menu_item(ui, "Add to Swatches", g.is_some(), false) if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) {
&& let Some(g) = g set_pstate(ui.ctx(), HIDE_OPTIONS, !hidden);
{
app.run("swatch.new", super::paint_params(&Paint::Gradient(Box::new(g)))).ok();
} }
if menu_item(ui, "Reverse Gradient", current(app).is_some(), false) { ui.separator();
if menu_item(ui, "Add to Swatches", g.is_some(), false)
&& let Some(g) = &g
{
save_to_swatches(app, g);
}
if menu_item(ui, "Reverse Gradient", g.is_some(), false) {
edit(app, json!({"reverse": true}), Live::Released); edit(app, json!({"reverse": true}), Live::Released);
} }
if menu_item(ui, "Reset to White, Black", true, false) { if menu_item(ui, "Reset to White, Black", true, false) {
let d = Gradient::default(); set_stops(app, &Gradient::default().stops, None, Live::Released);
edit(app, json!({"stops": stops_json(&d.stops)}), Live::Released);
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use egui::{Event, Modifiers, PointerButton};
use vectorcraft_engine::Session;
use super::*; use super::*;
#[test] #[test]
@ -361,4 +581,251 @@ mod tests {
assert_eq!(j.as_array().unwrap().len(), 2); assert_eq!(j.as_array().unwrap().len(), 2);
assert_eq!(j[1]["midpoint"], json!(0.5)); assert_eq!(j[1]["midpoint"], json!(0.5));
} }
#[test]
fn dropped_colours_recolour_or_add_an_opaque_stop() {
let mut g = Gradient::default();
g.stops[1].opacity = 0.5;
let red = Color::rgb(1.0, 0.0, 0.0);
let (v, i) = drop_color(&g, Some(1), 0.3, red);
assert_eq!((v.len(), i, v[1].color, v[1].opacity), (2, 1, red, 1.0));
let (v, i) = drop_color(&g, None, 0.3, red);
assert_eq!((v.len(), i, v[1].color, v[1].offset, v[1].opacity), (3, 1, red, 0.3, 1.0));
}
#[test]
fn dragged_stops_move_copy_or_swap() {
let g = Gradient::default();
let (v, i) = dragged_stops(&g.stops, 0, 0.4, false, Some(1));
assert_eq!((v.len(), i, v[0].offset), (2, 0, 0.4));
let (v, i) = dragged_stops(&g.stops, 0, 0.4, true, None);
assert_eq!((v.len(), i, v[1].offset, v[1].color), (3, 1, 0.4, Color::WHITE));
let (v, i) = dragged_stops(&g.stops, 0, 1.0, true, Some(1));
assert_eq!((v.len(), i, v[0].color, v[1].color), (2, 0, Color::BLACK, Color::WHITE));
// Dropped on itself: a copy.
assert_eq!(dragged_stops(&g.stops, 0, 0.0, true, Some(0)).0.len(), 3);
}
// ---------- headless frames ----------
/// A selected rectangle filled with `fill` (`paint.setFill` params).
fn app(fill: Value) -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
app.run("paint.setFill", fill).unwrap();
app
}
/// A headless Gradient panel: frames share one context and a clock.
struct Panel {
ctx: egui::Context,
time: f64,
}
impl Panel {
fn new() -> Self {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
Self { ctx, time: 0.0 }
}
/// One frame with `events` (and `modifiers` held); returns the texts drawn and where.
fn frame(&mut self, app: &mut VectorcraftApp, events: Vec<Event>, modifiers: Modifiers) -> Vec<(String, Pos2)> {
fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
self.time += 0.1;
let mut events = events;
events.insert(0, Event::ModifiersChanged(modifiers));
let input = egui::RawInput {
time: Some(self.time),
events,
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))),
..Default::default()
};
let mut out = self.ctx.run_ui(input, |ui| show(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
/// The rect of the widget its parent ui keyed `key` (last frame).
fn widget(&self, key: impl std::hash::Hash + std::fmt::Debug + Copy) -> Rect {
self.ctx
.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| w.id == w.parent_id.with(key)).map(|w| w.rect))
.expect("widget drawn")
}
fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) -> Vec<(String, Pos2)> {
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
self.frame(app, vec![Event::PointerMoved(at), b(true)], Modifiers::NONE);
self.frame(app, vec![b(false)], Modifiers::NONE);
self.frame(app, vec![], Modifiers::NONE)
}
/// Press at `from`, move to `to` in steps and release there, with `m` held.
fn drag(&mut self, app: &mut VectorcraftApp, from: Pos2, to: Pos2, m: Modifiers) {
let b = |at, pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: m };
self.frame(app, vec![Event::PointerMoved(from), b(from, true)], m);
for k in 1..=4 {
self.frame(app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))], m);
}
self.frame(app, vec![b(to, false)], m);
self.frame(app, vec![], Modifiers::NONE);
}
}
fn fill(app: &VectorcraftApp) -> GradientPaint {
match crate::panels::current_paints(app).0 {
Paint::Gradient(g) => *g,
p => panic!("expected a gradient fill, got {p:?}"),
}
}
fn hexes(app: &VectorcraftApp) -> Vec<String> {
fill(app).gradient.stops.iter().map(|s| s.color.to_hex()).collect()
}
#[test]
fn the_proxy_toggles_fill_and_stroke_and_stays_with_options_hidden() {
let mut app = app(json!({"gradient": {}}));
let mut p = Panel::new();
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
assert!(texts.iter().any(|(t, _)| t == "Opacity:"));
let stroke = p.widget("stroke-proxy");
p.click(&mut app, stroke.right_bottom() - vec2(2.0, 2.0));
assert!(!app.session.fill_active, "the Stroke proxy came to the front");
set_pstate(&p.ctx, HIDE_OPTIONS, true);
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
assert!(!texts.iter().any(|(t, _)| t == "Opacity:" || t == "Type:"), "{texts:?}");
let fill = p.widget("fill-proxy");
p.click(&mut app, fill.left_top() + vec2(2.0, 2.0));
assert!(app.session.fill_active, "the proxy works with the options hidden");
p.widget(("grad-stop", 1));
}
#[test]
fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() {
let stops = json!([{"offset": 0, "color": "#ffffff", "midpoint": 0.3}, {"offset": 1, "color": "#000000"}]);
let mut app = app(json!({"gradient": {"stops": stops}}));
app.run("gradient.selectStop", json!({"index": 1})).unwrap();
let mut p = Panel::new();
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
assert!(texts.iter().any(|(t, _)| t == "100%"), "the stop's location: {texts:?}");
let diamond = p.widget(("grad-mid", 0));
let texts = p.click(&mut app, diamond.center());
assert_eq!(app.session.selected_stop(), None, "the diamond replaces the stop selection");
assert!(texts.iter().any(|(t, _)| t == "30%"), "the midpoint: {texts:?}");
// Typing a location moves the midpoint.
let field = texts.iter().find(|(t, _)| t == "30%").unwrap().1;
p.click(&mut app, field);
let key = |k, modifiers| Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers };
p.frame(&mut app, vec![key(egui::Key::A, Modifiers::COMMAND), Event::Text("60".into())], Modifiers::NONE);
p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE);
assert!((fill(&app).gradient.stops[0].midpoint - 0.6).abs() < 1e-6, "{:?}", fill(&app).gradient.stops);
// Clicking a stop selects it again.
let stop = p.widget(("grad-stop", 0));
let texts = p.click(&mut app, stop.center());
assert_eq!(app.session.selected_stop(), Some(0));
assert!(texts.iter().any(|(t, _)| t == "0%"));
}
#[test]
fn the_thumbnail_menu_applies_gradient_swatches_and_saves_one() {
let mut app = app(json!({"color": "#336699"}));
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
app.run("swatch.new", json!({"name": "Dusk", "gradient": {"stops": stops}})).unwrap();
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let menu = p.widget("grad-swatches").center();
let texts = p.click(&mut app, menu);
let dusk = texts.iter().find(|(t, _)| t == "Dusk").unwrap_or_else(|| panic!("the menu lists the swatch: {texts:?}")).1;
assert!(texts.iter().any(|(t, _)| t.ends_with("Save to Swatches")));
p.click(&mut app, dusk);
assert_eq!(hexes(&app), ["#ff0000", "#0000ff"]);
// Save to Swatches adds the current gradient.
let before = gradient_swatches(&app).len();
let texts = p.click(&mut app, menu);
let save = texts.iter().find(|(t, _)| t.ends_with("Save to Swatches")).unwrap().1;
p.click(&mut app, save);
assert_eq!(gradient_swatches(&app).len(), before + 1);
}
#[test]
fn alt_drag_copies_a_stop_and_alt_dropping_on_a_stop_swaps_colours() {
let mut app = app(json!({"gradient": {}}));
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
let mid = pos2((a.x + b.x) / 2.0, a.y);
let offsets = |app: &VectorcraftApp| fill(app).gradient.stops.iter().map(|s| (s.offset * 10.0).round() / 10.0).collect::<Vec<_>>();
// A plain drag moves the stop.
p.drag(&mut app, a, mid, Modifiers::NONE);
assert_eq!(offsets(&app), [0.5, 1.0]);
app.run("edit.undo", json!({})).unwrap();
// Alt leaves the original and drags a copy, selected on release; one undo step.
p.frame(&mut app, vec![], Modifiers::NONE);
p.drag(&mut app, a, mid, Modifiers::ALT);
assert_eq!((offsets(&app), hexes(&app)), (vec![0.0, 0.5, 1.0], vec!["#ffffff".to_string(), "#ffffff".into(), "#000000".into()]));
assert_eq!(app.session.selected_stop(), Some(1));
// Alt-dropping the first stop on the last swaps their colours.
p.drag(&mut app, a, b, Modifiers::ALT);
assert_eq!((offsets(&app), hexes(&app)), (vec![0.0, 0.5, 1.0], vec!["#000000".to_string(), "#ffffff".into(), "#ffffff".into()]));
app.run("edit.undo", json!({})).unwrap();
app.run("edit.undo", json!({})).unwrap();
assert_eq!(hexes(&app), ["#ffffff", "#000000"]);
}
#[test]
fn a_dropped_swatch_adds_or_recolours_a_stop() {
let mut app = app(json!({"gradient": {}}));
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
let drop = |p: &mut Panel, app: &mut VectorcraftApp, at: Pos2| {
egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(Color::rgb(1.0, 0.0, 0.0))));
p.frame(app, vec![Event::PointerMoved(at)], Modifiers::NONE);
let up = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE };
p.frame(app, vec![up], Modifiers::NONE);
};
drop(&mut p, &mut app, pos2((a.x + b.x) / 2.0, a.y - 20.0));
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]);
p.frame(&mut app, vec![], Modifiers::NONE);
let last = p.widget(("grad-stop", 2)).center();
drop(&mut p, &mut app, last);
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#ff0000"]);
}
#[test]
fn double_clicking_a_stop_opens_the_shared_popover_beside_it() {
let mut app = app(json!({"gradient": {}}));
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let stop = p.widget(("grad-stop", 1));
let b = |pressed| Event::PointerButton { pos: stop.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
p.frame(&mut app, vec![Event::PointerMoved(stop.center()), b(true), b(false), b(true), b(false)], Modifiers::NONE);
let d = app.ui.dialog.clone().expect("the popover opened");
assert_eq!(
(d.kind.as_str(), &d.fields["index"], &d.fields["screen"]),
("gradientStop", &json!(1), &json!([stop.left(), stop.bottom() + 4.0]))
);
assert_eq!(app.session.selected_stop(), Some(1));
}
#[test]
fn the_stop_eyedropper_arms_the_eyedropper_for_the_stop() {
let mut app = app(json!({"gradient": {}}));
app.select_tool("gradient");
app.run("gradient.selectStop", json!({"index": 0})).unwrap();
let texts = Panel::new().frame(&mut app, vec![], Modifiers::NONE);
assert!(texts.iter().any(|(t, _)| t == "#FFFFFF"), "the stop's colour row: {texts:?}");
stop_eyedropper(&mut app);
assert_eq!((app.session.tool_id(), app.session.tool_options()), ("eyedropper", json!({"stop": "gradient"})));
}
} }

View File

@ -1,14 +1,23 @@
//! Graphic Styles panel: thumbnail grid or list of the document's styles. //! Graphic Styles panel: thumbnail grid or list of the document's styles. Clicking a style applies
//! it to the selection (Alt-click adds it on top), Shift/Cmd-click selects several, double-click
//! opens Graphic Style Options.
use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2}; use egui::{Rect, Response, Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json; use serde_json::json;
use vectorcraft_doc::Appearance; use vectorcraft_doc::{Appearance, GraphicStyle};
use super::{pstate, selection_len, set_pstate}; use super::{alt_held, pstate, selection_len, set_pstate};
use crate::VectorcraftApp; use crate::VectorcraftApp;
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::widgets::{self, menu_item}; use crate::widgets::{self, menu_item};
/// What a click on a style does (run after drawing, once the document is no longer borrowed).
enum Click {
Apply(String),
Toggle(String),
Options(String),
}
fn preview(ui: &Ui, r: Rect, ap: &Appearance) { fn preview(ui: &Ui, r: Rect, ap: &Appearance) {
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
let inner = r.shrink(r.width() * 0.18); let inner = r.shrink(r.width() * 0.18);
@ -29,13 +38,31 @@ fn preview(ui: &Ui, r: Rect, ap: &Appearance) {
} }
} }
/// The click on a style's row or tile.
fn clicked(ui: &Ui, resp: &Response, name: &str) -> Option<Click> {
if resp.double_clicked() {
Some(Click::Options(name.to_string()))
} else if resp.clicked() {
let m = ui.input(|i| i.modifiers);
Some(if m.shift || m.command { Click::Toggle(name.to_string()) } else { Click::Apply(name.to_string()) })
} else {
None
}
}
/// The styles selected in the panel.
fn selected(ctx: &egui::Context) -> Vec<String> {
pstate(ctx, "gs-sel")
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
let styles: Vec<(String, Appearance)> = // The document is shared (an `Arc`): holding it while drawing copies nothing.
app.session.active().map(|d| d.doc.graphic_styles.iter().map(|g| (g.name.clone(), g.appearance.clone())).collect()).unwrap_or_default(); let doc = app.session.active().map(|d| d.doc.clone());
let sel: Option<String> = pstate(ui.ctx(), "gs-sel"); let styles: &[GraphicStyle] = doc.as_ref().map_or(&[], |d| &d.graphic_styles);
let sel = selected(ui.ctx());
let list: bool = pstate(ui.ctx(), "gs-list"); let list: bool = pstate(ui.ctx(), "gs-list");
let mut clicked = None; let mut click = None;
widgets::list_box(ui, |ui| { widgets::list_box(ui, |ui| {
ui.set_min_height(120.0); ui.set_min_height(120.0);
ui.set_width(ui.available_width()); ui.set_width(ui.available_width());
@ -44,93 +71,127 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
return; return;
} }
if list { if list {
for (name, ap) in &styles { for g in styles {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
if sel.as_deref() == Some(name) { if sel.contains(&g.name) {
ui.painter().rect_filled(r, 0.0, t.row_selected); ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() { } else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover); ui.painter().rect_filled(r, 0.0, t.hover);
} }
preview(ui, Rect::from_min_size(r.left_center() + vec2(4.0, -9.0), vec2(18.0, 18.0)), ap); preview(ui, Rect::from_min_size(r.left_center() + vec2(4.0, -9.0), vec2(18.0, 18.0)), &g.appearance);
ui.painter().text(r.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); ui.painter().text(r.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.0), t.text);
if resp.clicked() { if let Some(c) = clicked(ui, &resp, &g.name) {
clicked = Some(name.clone()); click = Some(c);
} }
} }
} else { } else {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(3.0, 3.0); ui.spacing_mut().item_spacing = vec2(3.0, 3.0);
for (name, ap) in &styles { for g in styles {
let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click()); let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click());
preview(ui, r, ap); preview(ui, r, &g.appearance);
let on = sel.as_deref() == Some(name.as_str()); let on = sel.contains(&g.name);
ui.painter().rect_stroke( let color = if on {
r, t.accent
0.0, } else if resp.hovered() {
Stroke::new( t.text
if on { 2.0 } else { 1.0 }, } else {
if on { t.border
t.accent };
} else if resp.hovered() { ui.painter().rect_stroke(r, 0.0, Stroke::new(if on { 2.0 } else { 1.0 }, color), StrokeKind::Inside);
t.text let resp = resp.on_hover_text(&g.name);
} else { if let Some(c) = clicked(ui, &resp, &g.name) {
t.border click = Some(c);
},
),
StrokeKind::Inside,
);
if resp.on_hover_text(name).clicked() {
clicked = Some(name.clone());
} }
} }
}); });
} }
}); });
if let Some(name) = clicked { match click {
set_pstate(ui.ctx(), "gs-sel", Some(name.clone())); Some(Click::Apply(name)) => {
if selection_len(app) > 0 { if selection_len(app) > 0 {
app.run("graphicStyle.apply", json!({"name": name})).ok(); app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok();
}
set_pstate(ui.ctx(), "gs-sel", vec![name]);
} }
Some(Click::Toggle(name)) => {
let mut sel = sel;
match sel.iter().position(|n| *n == name) {
Some(i) => {
sel.remove(i);
}
None => sel.push(name),
}
set_pstate(ui.ctx(), "gs-sel", sel);
}
Some(Click::Options(name)) => {
app.run("ui.graphicStyleOptions", json!({ "name": name })).ok();
}
None => {}
} }
let sel: Option<String> = pstate(ui.ctx(), "gs-sel"); let sel = selected(ui.ctx());
let has_sel = selection_len(app) > 0; let has_sel = selection_len(app) > 0;
let linked = app.session.selection_graphic_style().is_some();
widgets::bottom_bar(ui, |ui| { widgets::bottom_bar(ui, |ui| {
widgets::icon_button_enabled(ui, "library", "Graphic Style Libraries (on the roadmap)", false, false, 24.0); widgets::icon_button_enabled(ui, "library", "Graphic Style Libraries (on the roadmap)", false, false, 24.0);
widgets::icon_button_enabled(ui, "link-2-off", "Break Link to Graphic Style (on the roadmap)", false, false, 24.0); if widgets::icon_button_enabled(ui, "link-2-off", "Break Link to Graphic Style", false, linked, 24.0).clicked() {
ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); app.run("graphicStyle.breakLink", json!({})).ok();
if widgets::icon_button_enabled(ui, "dc-new-item", "New Graphic Style", false, has_sel, 24.0).clicked() {
app.run("graphicStyle.new", json!({})).ok();
} }
if widgets::icon_button_enabled(ui, "trash-2", "Delete Graphic Style", false, sel.is_some(), 24.0).clicked() ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0));
&& let Some(n) = &sel if widgets::icon_button_enabled(ui, "dc-new-item", "New Graphic Style (Alt-click to name it)", false, has_sel, 24.0).clicked() {
&& app.run("graphicStyle.delete", json!({"name": n})).is_ok() new_style(app, alt_held(ui));
{ }
set_pstate::<Option<String>>(ui.ctx(), "gs-sel", None); if widgets::icon_button_enabled(ui, "trash-2", "Delete Graphic Style", false, !sel.is_empty(), 24.0).clicked() {
delete(app, ui.ctx(), &sel);
} }
}); });
} }
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { /// A new style from the selection, first asking for its name (`ask`) in Graphic Style Options.
let sel: Option<String> = pstate(ui.ctx(), "gs-sel"); fn new_style(app: &mut VectorcraftApp, ask: bool) {
let list: bool = pstate(ui.ctx(), "gs-list"); if ask {
if menu_item(ui, "New Graphic Style…", selection_len(app) > 0, false) { app.run("ui.graphicStyleOptions", json!({})).ok();
} else {
app.run("graphicStyle.new", json!({})).ok(); app.run("graphicStyle.new", json!({})).ok();
} }
if menu_item(ui, "Duplicate Graphic Style", sel.is_some(), false) }
&& let Some(n) = &sel
fn delete(app: &mut VectorcraftApp, ctx: &egui::Context, sel: &[String]) {
if app.run("graphicStyle.delete", json!({ "names": sel })).is_ok() {
set_pstate(ctx, "gs-sel", Vec::<String>::new());
}
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let sel = selected(ui.ctx());
let one = (sel.len() == 1).then(|| sel[0].clone());
let has_doc = app.session.active().is_some();
let list: bool = pstate(ui.ctx(), "gs-list");
if menu_item(ui, "New Graphic Style…", selection_len(app) > 0, false) {
new_style(app, true);
}
if menu_item(ui, "Duplicate Graphic Style", one.is_some(), false)
&& let Some(n) = &one
{ {
app.run("graphicStyle.duplicate", json!({"name": n})).ok(); app.run("graphicStyle.duplicate", json!({ "name": n })).ok();
} }
menu_item(ui, "Merge Graphic Styles", false, false); menu_item(ui, "Merge Graphic Styles", false, false);
if menu_item(ui, "Delete Graphic Style", sel.is_some(), false) if menu_item(ui, "Delete Graphic Style", !sel.is_empty(), false) {
&& let Some(n) = &sel delete(app, ui.ctx(), &sel);
{ }
app.run("graphicStyle.delete", json!({"name": n})).ok(); if menu_item(ui, "Break Link to Graphic Style", app.session.selection_graphic_style().is_some(), false) {
app.run("graphicStyle.breakLink", json!({})).ok();
} }
menu_item(ui, "Break Link to Graphic Style", false, false);
ui.separator(); ui.separator();
menu_item(ui, "Select All Unused", false, false); if menu_item(ui, "Select All Unused", has_doc, false)
menu_item(ui, "Sort by Name", false, false); && let Ok(r) = app.run("graphicStyle.unused", json!({}))
{
let names: Vec<String> = serde_json::from_value(r["names"].clone()).unwrap_or_default();
set_pstate(ui.ctx(), "gs-sel", names);
}
if menu_item(ui, "Sort by Name", has_doc, false) {
app.run("graphicStyle.sortByName", json!({})).ok();
}
ui.separator(); ui.separator();
if menu_item(ui, "Thumbnail View", true, !list) { if menu_item(ui, "Thumbnail View", true, !list) {
set_pstate(ui.ctx(), "gs-list", false); set_pstate(ui.ctx(), "gs-list", false);
@ -139,6 +200,59 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
set_pstate(ui.ctx(), "gs-list", true); set_pstate(ui.ctx(), "gs-list", true);
} }
ui.separator(); ui.separator();
menu_item(ui, "Graphic Style Options…", false, false); if menu_item(ui, "Graphic Style Options…", one.is_some(), false)
&& let Some(n) = &one
{
app.run("ui.graphicStyleOptions", json!({ "name": n })).ok();
}
menu_item(ui, "Open Graphic Style Library", false, false); menu_item(ui, "Open Graphic Style Library", false, false);
} }
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
/// The texts `draw` paints in one headless frame.
fn frame(ctx: &egui::Context, draw: impl FnMut(&mut Ui)) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let mut out = ctx.run_ui(egui::RawInput::default(), draw);
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
#[test]
fn list_view_names_the_styles_and_deletes_the_selected_ones() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
app.session.execute("graphicStyle.new", &json!({"name": "Mine"})).unwrap();
let ctx = egui::Context::default();
set_pstate(&ctx, "gs-list", true);
let texts = frame(&ctx, |ui| {
show(&mut app, ui);
menu(&mut app, ui);
});
// The Appearance panel names the style the object is linked to.
let shown = frame(&ctx, |ui| super::super::appearance::show(&mut app, ui));
assert!(shown.iter().any(|t| t == "Rectangle: Mine"), "{shown:?}");
assert!(texts.iter().any(|t| t == "Mine") && texts.iter().any(|t| t == "Sunshine"), "{texts:?}");
// Select All Unused picks every style but the one the rectangle is linked to.
let unused: Vec<String> = serde_json::from_value(app.session.execute("graphicStyle.unused", &json!({})).unwrap()["names"].clone()).unwrap();
assert_eq!(unused.len(), 4);
set_pstate(&ctx, "gs-sel", unused.clone());
frame(&ctx, |ui| show(&mut app, ui));
delete(&mut app, &ctx, &unused);
assert!(selected(&ctx).is_empty());
let names: Vec<&str> = app.session.active().unwrap().doc.graphic_styles.iter().map(|g| g.name.as_str()).collect();
assert_eq!(names, ["Mine"]);
}
}

View File

@ -25,22 +25,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let mut expanded: HashSet<u64> = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_else(|| doc.layers.iter().map(|l| l.id.0).collect()); let mut expanded: HashSet<u64> = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_else(|| doc.layers.iter().map(|l| l.id.0).collect());
let mut actions: Vec<(String, serde_json::Value)> = vec![]; let mut actions: Vec<(String, serde_json::Value)> = vec![];
// Search field ("Search All"). // Search field ("Search All").
let search_id = egui::Id::new("layers-search"); crate::widgets::search_field(ui, egui::Id::new("layers-search"), "Search All");
let mut query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, t.input_border))
.corner_radius(egui::CornerRadius::same(2))
.inner_margin(egui::Margin::symmetric(8, 5))
.show(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut query)
.frame(egui::Frame::NONE)
.hint_text(egui::RichText::new("Search All").italics())
.desired_width(ui.available_width()),
);
});
ui.data_mut(|d| d.insert_temp(search_id, query.clone()));
ui.add_space(6.0); ui.add_space(6.0);
let h = ui.available_height() - 34.0; let h = ui.available_height() - 34.0;
egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| { egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| {

View File

@ -356,3 +356,7 @@ mod tests {
} }
#[cfg(test)] #[cfg(test)]
mod tests_appearance; mod tests_appearance;
#[cfg(test)]
mod tests_effectedit;
#[cfg(test)]
mod tests_stroke;

View File

@ -307,10 +307,9 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
} }
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {
let r = widgets::flat_button(ui, "fx", 34.0); // The fx button opens the effect menu, as the Appearance panel's does.
if r.on_hover_text("Effects").clicked() { let r = widgets::flat_button(ui, "fx", 34.0).on_hover_text("Add New Effect");
app.ui.open_panel = Some("appearance".into()); egui::Popup::menu(&r).show(|ui| super::appearance::fx_menu(app, ui));
}
if widgets::icon_button(ui, "ellipsis", "Appearance panel", false, 22.0).clicked() { if widgets::icon_button(ui, "ellipsis", "Appearance panel", false, 22.0).clicked() {
app.ui.open_panel = Some("appearance".into()); app.ui.open_panel = Some("appearance".into());
} }

View File

@ -3,7 +3,7 @@
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_doc::{ArrowAlign, Arrowhead, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile}; use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile};
use super::{current_stroke, pstate, set_pstate}; use super::{current_stroke, pstate, set_pstate};
use crate::theme::Tokens; use crate::theme::Tokens;
@ -38,6 +38,15 @@ pub fn dash_fields(pattern: &[f64]) -> [Option<f64>; 6] {
out out
} }
/// The dash/gap fields and alignment the Dashed Line section shows: the stroke's dashes, or with
/// dashes off the last ones turned off (`last`), at first 12 pt dashes fitted to corners.
fn dash_state(dash: Option<&Dash>, last: Option<([Option<f64>; 6], bool)>) -> ([Option<f64>; 6], bool) {
match dash {
Some(d) => (dash_fields(&d.pattern), d.align_corners),
None => last.unwrap_or(([Some(12.0), None, None, None, None, None], true)),
}
}
/// Six dash/gap fields → a dash pattern: stops at the first empty dash; a dash without a gap /// Six dash/gap fields → a dash pattern: stops at the first empty dash; a dash without a gap
/// repeats its length as the gap (Illustrator's behaviour). /// repeats its length as the gap (Illustrator's behaviour).
pub fn dash_pattern(fields: &[Option<f64>; 6]) -> Vec<f64> { pub fn dash_pattern(fields: &[Option<f64>; 6]) -> Vec<f64> {
@ -136,16 +145,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}); });
widgets::divider(ui); widgets::divider(ui);
// Dashed line. // Dashed line.
let dash = st.as_ref().and_then(|s| s.dash.clone()); let dash = st.as_ref().and_then(|s| s.dash.as_ref());
let fields: [Option<f64>; 6] = match &dash { let (fields, align_corners) = dash_state(dash, pstate(ui.ctx(), "stroke-dash-last"));
Some(d) => dash_fields(&d.pattern),
None => pstate::<Option<[Option<f64>; 6]>>(ui.ctx(), "stroke-dash-last").unwrap_or([Some(12.0), None, None, None, None, None]),
};
let align_corners = dash.as_ref().is_some_and(|d| d.align_corners);
ui.horizontal(|ui| { ui.horizontal(|ui| {
if widgets::check(ui, "Dashed Line", dash.is_some(), st.is_some()) { if widgets::check(ui, "Dashed Line", dash.is_some(), st.is_some()) {
if dash.is_some() { if dash.is_some() {
set_pstate(ui.ctx(), "stroke-dash-last", Some(fields)); set_pstate(ui.ctx(), "stroke-dash-last", Some((fields, align_corners)));
set(app, json!({"dash": null})); set(app, json!({"dash": null}));
} else { } else {
set(app, json!({"dash": dash_pattern(&fields), "alignDashes": align_corners})); set(app, json!({"dash": dash_pattern(&fields), "alignDashes": align_corners}));
@ -154,10 +159,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space((ui.available_width() - 56.0).max(0.0)); ui.add_space((ui.available_width() - 56.0).max(0.0));
let on = dash.is_some(); let on = dash.is_some();
if widgets::icon_button_enabled(ui, "dc-dash-exact", "Exact dash lengths", on && !align_corners, on, 24.0).clicked() { if widgets::icon_button_enabled(ui, "dc-dash-exact", "Exact dash lengths", on && !align_corners, on, 24.0).clicked() {
set(app, json!({"dash": dash_pattern(&fields), "alignDashes": false})); set(app, json!({"alignDashes": false}));
} }
if widgets::icon_button_enabled(ui, "dc-dash-align", "Fit dashes to corners and ends", on && align_corners, on, 24.0).clicked() { if widgets::icon_button_enabled(ui, "dc-dash-align", "Fit dashes to corners and ends", on && align_corners, on, 24.0).clicked() {
set(app, json!({"dash": dash_pattern(&fields), "alignDashes": true})); set(app, json!({"alignDashes": true}));
} }
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {
@ -173,7 +178,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} }
} }
if changed && dash.is_some() { if changed && dash.is_some() {
set(app, json!({"dash": dash_pattern(&nf), "alignDashes": align_corners})); // The command keeps the dash offset and alignment.
set(app, json!({"dash": dash_pattern(&nf)}));
} }
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {

View File

@ -1,13 +1,18 @@
//! Swatches panel: proxy, Recent Colors, thumbnail grid (15.5 pt tiles on a 17 pt pitch) or list, //! Swatches panel: proxy, Recent Colors, find field, thumbnail grid (15.5 pt tiles on a 17 pt pitch)
//! colour groups as folders, None/Registration first, bottom bar and panel menu. //! or list, colour groups as folders, None/Registration first, bottom bar and panel menu.
//!
//! Swatches drag to reorder, into and out of colour groups (`swatch.move`) and onto art (the active
//! proxy's paint command with the object's id) and onto the Gradient panel's ramp; a Fill/Stroke
//! proxy or the Gradient panel's thumbnail dropped on the panel becomes a swatch (`swatch.new`).
use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use egui::{Color32, Rect, Response, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json; use serde_json::{Value, json};
use vectorcraft_color::Paint; use vectorcraft_color::{Color, GradientKind, Paint};
use vectorcraft_doc::Document;
use super::{active_paint, pstate, set_pstate}; use super::{active_paint, pstate, set_pstate};
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::widgets::{self, menu_item, swatch_tile}; use crate::widgets::{self, PanelDrag, SwatchRows, menu_item, swatch_tile};
use crate::{VectorcraftApp, icons}; use crate::{VectorcraftApp, icons};
/// View modes of the swatch list. /// View modes of the swatch list.
@ -90,29 +95,38 @@ impl Entry {
Entry::Swatch { name, .. } | Entry::Folder(name) => name, Entry::Swatch { name, .. } | Entry::Folder(name) => name,
} }
} }
fn is_folder(&self) -> bool {
matches!(self, Entry::Folder(_))
}
} }
const REGISTRATION: &str = "[Registration]"; const REGISTRATION: &str = "[Registration]";
fn entries(app: &VectorcraftApp, kind: Kind) -> Vec<Entry> { /// The panel's rows for the kind filter and the find field's `query` (a case-insensitive name
/// match; a colour group whose name matches shows all its swatches).
fn entries(app: &VectorcraftApp, kind: Kind, query: &str) -> Vec<Entry> {
let Some(st) = app.session.active() else { return vec![] }; let Some(st) = app.session.active() else { return vec![] };
let d = &st.doc; let d = &st.doc;
let q = query.trim().to_lowercase();
let found = |n: &str| q.is_empty() || n.to_lowercase().contains(&q);
let mut out = vec![]; let mut out = vec![];
let sw = |s: &vectorcraft_color::Swatch| Entry::Swatch { name: s.name.clone(), paint: s.paint.clone(), global: s.global, spot: s.spot }; let sw = |s: &vectorcraft_color::Swatch| Entry::Swatch { name: s.name.clone(), paint: s.paint.clone(), global: s.global, spot: s.spot };
// None first, then Registration, then the rest (Illustrator's order). // None first, then Registration, then the rest (the reference app's order).
let (specials, rest): (Vec<_>, Vec<_>) = d.swatches.iter().partition(|s| s.paint.is_none()); let (specials, rest): (Vec<_>, Vec<_>) = d.swatches.iter().partition(|s| s.paint.is_none());
for s in specials.iter().filter(|s| kind.accepts(&s.paint, false)) { for s in specials.iter().filter(|s| kind.accepts(&s.paint, false) && found(&s.name)) {
out.push(sw(s)); out.push(sw(s));
} }
if matches!(kind, Kind::All | Kind::Color) { if matches!(kind, Kind::All | Kind::Color) && found(REGISTRATION) {
out.push(Entry::Registration); out.push(Entry::Registration);
} }
for s in rest.iter().filter(|s| kind.accepts(&s.paint, false)) { for s in rest.iter().filter(|s| kind.accepts(&s.paint, false) && found(&s.name)) {
out.push(sw(s)); out.push(sw(s));
} }
for g in &d.swatch_groups { for g in &d.swatch_groups {
let items: Vec<_> = g.swatches.iter().filter(|s| kind.accepts(&s.paint, true)).collect(); let all = found(&g.name);
if items.is_empty() && kind != Kind::Groups && kind != Kind::All { let items: Vec<_> = g.swatches.iter().filter(|s| kind.accepts(&s.paint, true) && (all || found(&s.name))).collect();
// Empty groups show only among all swatches or groups, and when the find field names them.
if items.is_empty() && !(all && matches!(kind, Kind::Groups | Kind::All)) {
continue; continue;
} }
out.push(Entry::Folder(g.name.clone())); out.push(Entry::Folder(g.name.clone()));
@ -121,15 +135,21 @@ fn entries(app: &VectorcraftApp, kind: Kind) -> Vec<Entry> {
out out
} }
/// Apply a clicked swatch to the active proxy (Alt: the inactive one). /// The paint command params that apply entry `e` (`paint.setFill`); none for a colour group.
fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) { fn swatch_params(e: &Entry) -> Option<Value> {
let params = match e { Some(match e {
Entry::Registration => json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}}), Entry::Registration => json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}}),
Entry::Swatch { paint, .. } if paint.is_none() => json!({"none": true}), Entry::Swatch { paint, .. } if paint.is_none() => json!({"none": true}),
Entry::Swatch { name, .. } => json!({"swatch": name}), Entry::Swatch { name, .. } => json!({"swatch": name}),
Entry::Folder(_) => return, Entry::Folder(_) => return None,
}; })
super::apply_click(app, ui, params); }
/// Apply a clicked swatch to the active proxy (Alt: the inactive one).
fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) {
if let Some(params) = swatch_params(e) {
super::apply_click(app, ui, params);
}
} }
/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). /// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size).
@ -187,6 +207,82 @@ fn draw_folder(ui: &Ui, r: Rect) {
icons::paint(ui, "dc-folder", r.expand(1.0), t.icon); icons::paint(ui, "dc-folder", r.expand(1.0), t.icon);
} }
/// The ink colours of the CMYK glyph (cyan, magenta, yellow, black) and the RGB glyph's bars.
const CMYK_INKS: [Color32; 4] = [Color32::from_rgb(0, 174, 239), Color32::from_rgb(236, 0, 140), Color32::from_rgb(255, 242, 0), Color32::BLACK];
const RGB_BARS: [Color32; 3] = [Color32::from_rgb(255, 0, 0), Color32::from_rgb(0, 200, 0), Color32::from_rgb(0, 0, 255)];
/// A swatch's kind in words, for list tooltips ("Global Process Color, CMYK").
fn describe(paint: &Paint, global: bool, spot: bool) -> String {
match paint {
Paint::Solid { color, .. } => {
let kind = if spot {
"Spot Color"
} else if global {
"Global Process Color"
} else {
"Process Color"
};
format!("{kind}, {}", color.model_name())
}
Paint::Gradient(g) => format!("{} Gradient", g.gradient.kind.label()),
Paint::Pattern { .. } => "Pattern".into(),
Paint::None => "None".into(),
}
}
/// The kind and colour-mode icons at the right of list row `r`: spot (a dot in a ring), global (a
/// square with a filled corner) or process colours (a square), gradients and patterns; then a
/// colour's model (CMYK as four ink quarters, RGB as three bars, Gray as a grey square).
fn list_icons(ui: &Ui, r: Rect, paint: &Paint, global: bool, spot: bool) {
let t = Tokens::get(ui.ctx());
let p = ui.painter();
let mode = Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(10.0, 10.0));
let kind = mode.translate(vec2(-16.0, 0.0));
let line = Stroke::new(1.0, t.icon);
match paint {
Paint::Solid { color, .. } => {
if spot {
p.circle_stroke(kind.center(), 4.5, line);
p.circle_filled(kind.center(), 1.8, t.icon);
} else {
p.rect_stroke(kind, 0.0, line, StrokeKind::Inside);
if global {
let c = kind.right_bottom();
p.add(Shape::convex_polygon(vec![c, c - vec2(6.0, 0.0), c - vec2(0.0, 6.0)], t.icon, Stroke::NONE));
}
}
match color {
Color::Cmyk { .. } => {
let q = mode.size() / 2.0;
for (i, ink) in CMYK_INKS.into_iter().enumerate() {
p.rect_filled(Rect::from_min_size(mode.min + vec2((i % 2) as f32 * q.x, (i / 2) as f32 * q.y), q), 0.0, ink);
}
}
Color::Rgb { .. } => {
let w = mode.width() / 3.0;
for (i, bar) in RGB_BARS.into_iter().enumerate() {
p.rect_filled(Rect::from_min_size(mode.min + vec2(i as f32 * w, 0.0), vec2(w, mode.height())), 0.0, bar);
}
}
Color::Gray { .. } => {
p.rect_filled(mode, 0.0, Color32::from_gray(128));
}
}
p.rect_stroke(mode, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
}
Paint::Gradient(g) => {
let icon = match g.gradient.kind {
GradientKind::Linear => "dc-grad-linear",
GradientKind::Radial => "dc-grad-radial",
GradientKind::Freeform => "dc-grad-freeform",
};
icons::paint(ui, icon, kind.expand(1.0), t.icon);
}
Paint::Pattern { .. } => icons::paint(ui, "grid-3x3", kind.expand(1.0), t.icon),
Paint::None => {}
}
}
/// The swatches and colour groups selected in the panel, in click order, without names that no /// The swatches and colour groups selected in the panel, in click order, without names that no
/// longer exist (deleted, renamed or undone). /// longer exist (deleted, renamed or undone).
fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec<String> { fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec<String> {
@ -196,6 +292,16 @@ fn selection(app: &VectorcraftApp, ui: &Ui) -> Vec<String> {
names names
} }
/// The names the panel highlights: `sel` plus the swatches of selected colour groups (clicking a
/// folder selects the whole group).
fn highlighted<'a>(d: &'a Document, sel: &'a [String]) -> Vec<&'a str> {
let mut out: Vec<&str> = sel.iter().map(String::as_str).collect();
for g in d.swatch_groups.iter().filter(|g| sel.contains(&g.name)) {
out.extend(g.swatches.iter().map(|w| w.name.as_str()));
}
out
}
/// The selection after a click on `name`: Cmd/Ctrl toggles it, Shift extends from the last clicked /// The selection after a click on `name`: Cmd/Ctrl toggles it, Shift extends from the last clicked
/// name over `order` (the names as displayed), a plain click selects it alone. /// name over `order` (the names as displayed), a plain click selects it alone.
fn click_selection(ui: &Ui, mut sel: Vec<String>, order: &[&str], name: &str, m: egui::Modifiers) -> Vec<String> { fn click_selection(ui: &Ui, mut sel: Vec<String>, order: &[&str], name: &str, m: egui::Modifiers) -> Vec<String> {
@ -224,6 +330,222 @@ fn click_selection(ui: &Ui, mut sel: Vec<String>, order: &[&str], name: &str, m:
sel sel
} }
/// A drag released on the panel.
enum Drop {
/// Swatches (or colour groups) dropped before or `after` row `target`, into folder `target`, or
/// at the end (`None`).
Move { names: Vec<String>, target: Option<String>, after: bool },
/// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row
/// `target` (or between rows) becomes a swatch.
New { paint: Paint, target: Option<String> },
}
impl Drop {
/// What releasing `d` before or `after` row `target` (`None`: at the end) does: move the rows
/// it carries, or make a swatch of a paint dragged from elsewhere (appearances: nothing).
fn of(d: &PanelDrag, target: Option<String>, after: bool) -> Option<Self> {
match d {
PanelDrag::Paint { rows: Some(r), .. } => Some(Drop::Move { names: r.names.clone(), target, after }),
PanelDrag::Paint { paint, .. } => Some(Drop::New { paint: paint.clone(), target }),
PanelDrag::Appearance(_) => None,
}
}
}
/// The drag held over `resp`, if the panel takes it (paints, not the Appearance panel's thumbnail).
fn held(resp: &Response) -> Option<std::sync::Arc<PanelDrag>> {
resp.dnd_hover_payload::<PanelDrag>().filter(|d| matches!(**d, PanelDrag::Paint { .. }))
}
/// The drag a tile or row of `e` starts, moving rows `names`. Registration and colour groups have
/// no paint of their own (the chip under the pointer draws Registration's mark).
fn drag_of(e: &Entry, names: Vec<String>) -> PanelDrag {
let paint = if let Entry::Swatch { paint, .. } = e { paint.clone() } else { Paint::None };
let rows = SwatchRows { grabbed: e.name().to_string(), names, groups: e.is_folder() };
PanelDrag::Paint { paint, params: swatch_params(e).unwrap_or_default(), rows: Some(rows) }
}
/// What the tiles or rows saw this frame.
#[derive(Default)]
struct TileEvents {
clicked: Option<(Entry, egui::Modifiers)>,
/// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing).
edit: Option<String>,
drop: Option<Drop>,
/// A drag is held over a tile or row.
over: bool,
}
/// Clicks, double-clicks, tooltips and drag and drop of the tile or row `resp` of entry `e`
/// (`items`: the panel's rows, `sel`: its selection, `list`: rows stack downwards). A drag from it
/// starts a [`PanelDrag`] carrying the rows it moves; see [`tile_drop`] for drags held over it.
fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String], list: bool, ev: &mut TileEvents) {
let name = e.name();
if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() {
ev.edit = Some(name.to_string());
}
if resp.drag_started() {
let with_sel = sel.iter().any(|s| s == name);
let names = items
.iter()
.filter(|x| x.is_folder() == e.is_folder() && (x.name() == name || (with_sel && sel.iter().any(|s| s == x.name()))))
.map(|x| x.name().to_string())
.collect();
egui::DragAndDrop::set_payload(ui.ctx(), drag_of(e, names));
}
tile_drop(ui, &resp, e, list, ev);
let resp = match e {
Entry::Folder(n) => resp.on_hover_text(format!("Color Group: {n}")),
Entry::Swatch { paint, global, spot, .. } if list => resp.on_hover_ui(|ui| {
ui.label(format!("{name} ({})", describe(paint, *global, *spot)));
}),
_ => resp.on_hover_text(name),
};
if resp.clicked() {
ev.clicked = Some((e.clone(), ui.input(|i| i.modifiers)));
}
}
/// A drag held over the tile or row `resp` of entry `e` shows where it would land (before or after
/// it by the pointer's half, or into a folder); a release there sets the drop.
fn tile_drop(ui: &Ui, resp: &Response, e: &Entry, list: bool, ev: &mut TileEvents) {
let Some(d) = held(resp) else { return };
let PanelDrag::Paint { rows, .. } = &*d else { return };
let name = e.name();
ev.over = true;
// Swatches dropped on one of themselves stay where they are.
if rows.as_ref().is_some_and(|r| r.names.iter().any(|n| n == name)) {
resp.dnd_release_payload::<PanelDrag>();
return;
}
let t = Tokens::get(ui.ctx());
let r = resp.rect;
let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center());
let after = if list { at.y > r.center().y } else { at.x > r.center().x };
// A paint from elsewhere, or swatches over a folder, go into it; otherwise a bar marks the slot.
if rows.as_ref().is_none_or(|d| e.is_folder() && !d.groups) {
ui.painter().rect_stroke(r.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside);
} else if list {
let y = if after { r.bottom() } else { r.top() };
ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent));
} else {
let x = if after { r.right() + 0.75 } else { r.left() - 0.75 };
ui.painter().line_segment([pos2(x, r.top() - 1.0), pos2(x, r.bottom() + 1.0)], Stroke::new(2.0, t.accent));
}
if resp.dnd_release_payload::<PanelDrag>().is_some() {
ev.drop = Drop::of(&d, Some(name.to_string()), after);
}
}
/// Drops between and after the tiles (`zone`: the list's viewport) go to the end of the ungrouped
/// swatches (colour groups to the end of the groups); the list is outlined while one is held there.
fn zone_input(ui: &Ui, zone: &Response, ev: &mut TileEvents) {
let Some(d) = held(zone) else { return };
if !ev.over {
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
}
if zone.dnd_release_payload::<PanelDrag>().is_some() {
ev.drop = Drop::of(&d, None, false);
}
}
/// The colour group `name` is, or the one swatch `name` belongs to.
fn group_of(d: &Document, name: &str) -> Option<String> {
if d.swatch_groups.iter().any(|g| g.name == name) {
return Some(name.to_string());
}
d.swatch_group_of(name).map(|g| d.swatch_groups[g].name.clone())
}
/// `swatch.move` params for dropping `names` before or `after` row `target`, into folder `target`
/// (swatches), or at the end (`target` None); `None` when nothing would move. Only solid colours go
/// into colour groups; None and Registration stay put.
fn move_params(d: &Document, names: &[String], target: Option<&str>, after: bool) -> Option<Value> {
if target.is_some_and(|t| names.iter().any(|n| n == t)) {
return None;
}
let group_index = |n: &str| d.swatch_groups.iter().position(|g| g.name == n);
if names.iter().all(|n| group_index(n).is_some()) {
let to = match target {
None => d.swatch_groups.len(),
// Over a group's folder or one of its swatches: next to that group; else before the first.
Some(t) => group_index(t).or_else(|| d.swatch_group_of(t)).map_or(0, |i| i + usize::from(after)),
};
return Some(json!({"names": names, "to": to}));
}
let (group, to) = match target {
Some(t) if group_index(t).is_some() => (Some(t), None),
Some(REGISTRATION) => (None, Some(d.swatches.iter().position(|w| !w.paint.is_none()).unwrap_or(0))),
Some(t) => {
let (group, list) = match d.swatch_group_of(t) {
Some(g) => (Some(d.swatch_groups[g].name.as_str()), &d.swatch_groups[g].swatches),
None => (None, &d.swatches),
};
(group, list.iter().position(|w| w.name == t).map(|i| i + usize::from(after)))
}
None => (None, None),
};
let movable: Vec<&String> =
names.iter().filter(|n| d.swatch(n).is_some_and(|w| !w.paint.is_none() && (group.is_none() || w.paint.color().is_some()))).collect();
(!movable.is_empty()).then(|| json!({"names": movable, "to": to, "group": group}))
}
/// Run a drop on the panel: move the swatches or make a swatch of the proxy's paint (a colour
/// dropped on a folder or a grouped swatch joins that group).
fn apply_drop(app: &mut VectorcraftApp, drop: Drop) {
let Some(d) = app.session.active().map(|st| &st.doc) else { return };
let (cmd, params) = match drop {
Drop::Move { names, target, after } => match move_params(d, &names, target.as_deref(), after) {
Some(p) => ("swatch.move", p),
None => return,
},
Drop::New { paint, target } => {
let mut p = super::paint_params(&paint);
if paint.color().is_some()
&& let Some(g) = target.and_then(|t| group_of(d, &t))
{
p["group"] = json!(g);
}
("swatch.new", p)
}
};
if let Err(e) = app.run(cmd, params) {
app.status(e);
}
}
/// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches,
/// a Fill/Stroke proxy, the Gradient panel's thumbnail), or the fill of the object whose
/// appearance the Appearance panel's thumbnail carries.
pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) {
let Some(d) = egui::DragAndDrop::payload::<PanelDrag>(ctx) else { return };
let (paint, registration) = match &*d {
// Colour groups paint nothing.
PanelDrag::Paint { params, .. } if params.is_null() => return,
PanelDrag::Paint { paint, rows, .. } => (std::borrow::Cow::Borrowed(paint), rows.as_ref().is_some_and(|r| r.grabbed == REGISTRATION)),
PanelDrag::Appearance(id) => match app.session.active().and_then(|st| st.doc.node(*id)) {
Some(n) => (std::borrow::Cow::Owned(n.appearance.fill_paint()), false),
None => return,
},
};
let Some(at) = ctx.pointer_hover_pos() else { return };
let area = egui::Area::new(egui::Id::new("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0));
area.interactable(false).show(ctx, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
if registration {
draw_registration(ui, r);
} else {
swatch_tile(ui, r, &paint, false, false);
pattern_thumb(app, ui, r.shrink(1.0), &paint);
}
});
}
/// The id of the find field's text (shown with the panel menu's Show Find Field).
fn find_id() -> egui::Id {
egui::Id::new("swatch-find")
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
if app.session.active().is_none() { if app.session.active().is_none() {
@ -247,7 +569,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
super::recent_colors_row(app, ui); super::recent_colors_row(app, ui);
widgets::divider(ui); widgets::divider(ui);
widgets::subheader(ui, "Swatch Tiles"); widgets::subheader(ui, "Swatch Tiles");
let items = entries(app, kind); let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), "Find") } else { String::new() };
let items = entries(app, kind, &query);
let active = active_paint(app); let active = active_paint(app);
let active_swatch = match &active { let active_swatch = match &active {
Paint::Solid { swatch: Some(n), .. } => Some(n.clone()), Paint::Solid { swatch: Some(n), .. } => Some(n.clone()),
@ -258,17 +581,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Without a selection the swatch of the active paint is highlighted. // Without a selection the swatch of the active paint is highlighted.
let fallback: Vec<String> = if selected.is_empty() { active_swatch.into_iter().collect() } else { vec![] }; let fallback: Vec<String> = if selected.is_empty() { active_swatch.into_iter().collect() } else { vec![] };
let sel = if selected.is_empty() { &fallback } else { &selected }; let sel = if selected.is_empty() { &fallback } else { &selected };
let is_sel = |name: &str| sel.iter().any(|s| s == name); let lit = app.session.active().map(|st| highlighted(&st.doc, sel)).unwrap_or_default();
let is_sel = |name: &str| lit.contains(&name);
let (tile, pitch) = view.tile(); let (tile, pitch) = view.tile();
let mut clicked: Option<(Entry, egui::Modifiers)> = None; let mut ev = TileEvents::default();
// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing).
let mut edit: Option<String> = None;
widgets::list_box(ui, |ui| { widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(if view == View::LargeThumb { 200.0 } else { 150.0 }).show(ui, |ui| { let max_height = if view == View::LargeThumb { 200.0 } else { 150.0 };
let out = egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(max_height).show(ui, |ui| {
ui.set_width(ui.available_width()); ui.set_width(ui.available_width());
if view.is_list() { if view.is_list() {
for e in &items { for e in &items {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), pitch), Sense::click()); let (_, r) = ui.allocate_space(vec2(ui.available_width(), pitch));
let resp = ui.interact(r, tile_id(e), Sense::click_and_drag());
let name = e.name(); let name = e.name();
if is_sel(name) { if is_sel(name) {
ui.painter().rect_filled(r, 0.0, t.row_selected); ui.painter().rect_filled(r, 0.0, t.row_selected);
@ -278,9 +602,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let chip = Rect::from_min_size(r.left_center() + vec2(4.0, -tile / 2.0), vec2(tile, tile)); let chip = Rect::from_min_size(r.left_center() + vec2(4.0, -tile / 2.0), vec2(tile, tile));
match e { match e {
Entry::Registration => draw_registration(ui, chip), Entry::Registration => draw_registration(ui, chip),
Entry::Swatch { paint, .. } => { Entry::Swatch { paint, global, spot, .. } => {
swatch_tile(ui, chip, paint, false, false); swatch_tile(ui, chip, paint, false, false);
pattern_thumb(app, ui, chip, paint); pattern_thumb(app, ui, chip, paint);
list_icons(ui, r, paint, *global, *spot);
} }
Entry::Folder(_) => draw_folder(ui, chip), Entry::Folder(_) => draw_folder(ui, chip),
} }
@ -291,27 +616,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
egui::FontId::proportional(12.0), egui::FontId::proportional(12.0),
t.text, t.text,
); );
// Kind markers at the right: global (white corner) / spot (dot) / process. tile_input(ui, resp, e, &items, sel, true, &mut ev);
if let Entry::Swatch { global, spot, paint, .. } = e {
let mk = Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(10.0, 10.0));
if *spot {
ui.painter().circle_filled(mk.center(), 3.5, t.icon);
} else if matches!(paint, Paint::Gradient(_)) {
icons::paint(ui, "dc-grad-linear", mk, t.icon);
} else if *global {
ui.painter().add(egui::Shape::convex_polygon(
vec![mk.left_bottom(), mk.right_bottom(), mk.right_top()],
t.icon,
Stroke::NONE,
));
}
}
if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() {
edit = Some(name.to_string());
}
if resp.on_hover_text(name).clicked() {
clicked = Some((e.clone(), ui.input(|i| i.modifiers)));
}
} }
} else { } else {
let w = ui.available_width(); let w = ui.available_width();
@ -320,7 +625,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let mut rows: Vec<Vec<&Entry>> = vec![vec![]]; let mut rows: Vec<Vec<&Entry>> = vec![vec![]];
for e in &items { for e in &items {
// A group folder starts a new row. // A group folder starts a new row.
if matches!(e, Entry::Folder(_)) && col > 0 { if e.is_folder() && col > 0 {
rows.push(vec![]); rows.push(vec![]);
col = 0; col = 0;
} }
@ -335,23 +640,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover()); let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover());
for (c, e) in row.into_iter().enumerate() { for (c, e) in row.into_iter().enumerate() {
let cell = Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile)); let cell = Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile));
let resp = ui.interact(cell, tile_id(e), Sense::click()); let resp = ui.interact(cell, tile_id(e), Sense::click_and_drag());
let name = e.name(); let name = e.name();
match e { match e {
Entry::Registration => draw_registration(ui, cell), Entry::Registration => draw_registration(ui, cell),
Entry::Swatch { paint, global, .. } => { Entry::Swatch { paint, global, spot, .. } => {
swatch_tile(ui, cell, paint, is_sel(name), resp.hovered()); swatch_tile(ui, cell, paint, is_sel(name), resp.hovered());
pattern_thumb(app, ui, cell.shrink(1.0), paint); pattern_thumb(app, ui, cell.shrink(1.0), paint);
if resp.double_clicked() { // Global colours get a white corner; spot colours a dot in it.
edit = Some(name.to_string());
}
if *global { if *global {
let k = cell.shrink(1.0); let k = cell.shrink(1.0).right_bottom();
ui.painter().add(egui::Shape::convex_polygon( let corner = vec![k, k - vec2(5.0, 0.0), k - vec2(0.0, 5.0)];
vec![k.right_bottom(), k.right_bottom() - vec2(5.0, 0.0), k.right_bottom() - vec2(0.0, 5.0)], ui.painter().add(Shape::convex_polygon(corner, Color32::WHITE, Stroke::NONE));
egui::Color32::WHITE, if *spot {
Stroke::NONE, ui.painter().circle_filled(k - vec2(1.6, 1.6), 1.0, Color32::BLACK);
)); }
} }
} }
Entry::Folder(_) => { Entry::Folder(_) => {
@ -361,22 +664,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} }
} }
} }
let resp = match e { tile_input(ui, resp, e, &items, sel, false, &mut ev);
Entry::Folder(n) => resp.on_hover_text(format!("Color Group: {n}")),
_ => resp.on_hover_text(name),
};
if resp.clicked() {
clicked = Some((e.clone(), ui.input(|i| i.modifiers)));
}
} }
} }
} }
}); });
let zone = ui.interact(out.inner_rect, ui.id().with("swatch-drop"), Sense::hover());
zone_input(ui, &zone, &mut ev);
}); });
if let Some(name) = edit { if let Some(name) = ev.edit {
app.run("ui.swatchOptions", json!({"name": name})).ok(); app.run("ui.swatchOptions", json!({"name": name})).ok();
} }
let selected = match clicked { if let Some(drop) = ev.drop {
apply_drop(app, drop);
}
let selected = match ev.clicked {
Some((e, m)) => { Some((e, m)) => {
let order: Vec<&str> = items.iter().map(Entry::name).collect(); let order: Vec<&str> = items.iter().map(Entry::name).collect();
let sel = click_selection(ui, selected, &order, e.name(), m); let sel = click_selection(ui, selected, &order, e.name(), m);
@ -392,7 +694,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
bottom(app, ui, &selected); bottom(app, ui, &selected);
} }
/// The interaction id of a tile in the thumbnail views (stable per swatch or group name). /// The interaction id of a tile or list row (stable per swatch or group name).
fn tile_id(e: &Entry) -> egui::Id { fn tile_id(e: &Entry) -> egui::Id {
egui::Id::new(("swatch-tile", e.name())) egui::Id::new(("swatch-tile", e.name()))
} }
@ -411,6 +713,18 @@ fn editable(app: &VectorcraftApp, sel: &[String]) -> Option<String> {
} }
} }
/// The selected solid-colour swatches in selection order (Merge Swatches keeps the first).
fn mergeable(app: &VectorcraftApp, sel: &[String]) -> Vec<String> {
let Some(st) = app.session.active() else { return vec![] };
sel.iter().filter(|n| st.doc.swatch(n).is_some_and(|w| w.paint.color().is_some())).cloned().collect()
}
/// The first selected colour group (Ungroup Color Group).
fn selected_group(app: &VectorcraftApp, sel: &[String]) -> Option<String> {
let d = &app.session.active()?.doc;
sel.iter().find(|n| d.swatch_groups.iter().any(|g| g.name == **n)).cloned()
}
/// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click). /// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click).
fn delete(app: &mut VectorcraftApp, names: Vec<String>, now: bool) { fn delete(app: &mut VectorcraftApp, names: Vec<String>, now: bool) {
let params = json!({"names": names}); let params = json!({"names": names});
@ -463,12 +777,7 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) {
/// The colour group the selection points at: the last selected group, or the group of the last /// The colour group the selection points at: the last selected group, or the group of the last
/// selected swatch. /// selected swatch.
fn target_group(app: &VectorcraftApp, sel: &[String]) -> Option<String> { fn target_group(app: &VectorcraftApp, sel: &[String]) -> Option<String> {
let d = &app.session.active()?.doc; group_of(&app.session.active()?.doc, sel.last()?)
let last = sel.last()?;
if d.swatch_groups.iter().any(|g| g.name == *last) {
return Some(last.clone());
}
d.swatch_group_of(last).map(|g| d.swatch_groups[g].name.clone())
} }
/// New Swatch from the active paint (a colour goes into the selected colour group): opens the /// New Swatch from the active paint (a colour goes into the selected colour group): opens the
@ -504,18 +813,36 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
{ {
app.run("swatch.duplicate", json!({"name": n})).ok(); app.run("swatch.duplicate", json!({"name": n})).ok();
} }
menu_item(ui, "Merge Swatches", false, false); // The first selected colour is kept.
let merge = mergeable(app, &selected);
if menu_item(ui, "Merge Swatches", merge.len() > 1, false) {
app.run("swatch.merge", json!({ "names": merge })).ok();
}
if menu_item(ui, "Delete Swatch", !del.is_empty(), false) { if menu_item(ui, "Delete Swatch", !del.is_empty(), false) {
delete(app, del, false); delete(app, del, false);
} }
menu_item(ui, "Ungroup Color Group", false, false); let group = selected_group(app, &selected);
menu_item(ui, "Select All Unused", false, false); if menu_item(ui, "Ungroup Color Group", group.is_some(), false)
menu_item(ui, "Add Used Colors", false, false); && let Some(g) = group
{
app.run("swatch.ungroup", json!({ "name": g })).ok();
}
if menu_item(ui, "Select All Unused", true, false)
&& let Ok(r) = app.run("swatch.unused", json!({}))
{
let names: Vec<String> = r["names"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
set_pstate(ui.ctx(), "swatch-selected", names);
}
if menu_item(ui, "Add Used Colors", true, false) {
app.run("swatch.addUsedColors", json!({})).ok();
}
ui.separator(); ui.separator();
if menu_item(ui, "Sort by Name", true, false) { if menu_item(ui, "Sort by Name", true, false) {
app.run("swatch.sortByName", json!({})).ok(); app.run("swatch.sortByName", json!({})).ok();
} }
menu_item(ui, "Sort by Kind", false, false); if menu_item(ui, "Sort by Kind", true, false) {
app.run("swatch.sortByKind", json!({})).ok();
}
ui.separator(); ui.separator();
for (v, label) in View::ALL { for (v, label) in View::ALL {
if menu_item(ui, label, true, v == view) { if menu_item(ui, label, true, v == view) {
@ -523,6 +850,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
} }
} }
ui.separator(); ui.separator();
let find: bool = pstate(ui.ctx(), "swatch-show-find");
if menu_item(ui, "Show Find Field", true, find) {
set_pstate(ui.ctx(), "swatch-show-find", !find);
}
ui.separator();
if menu_item(ui, "Swatch Options…", opts.is_some(), false) if menu_item(ui, "Swatch Options…", opts.is_some(), false)
&& let Some(n) = opts && let Some(n) = opts
{ {
@ -752,6 +1084,122 @@ mod tests {
assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()) }); assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()) });
} }
/// The centre of the tile or row of `name` as laid out by the last frame.
fn tile_center(ctx: &egui::Context, name: &str) -> Pos2 {
ctx.read_response(egui::Id::new(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center()
}
/// Press at `from`, drag to `to` and release there, a frame per step from `time`.
fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, time: f64) {
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
frame(app, ctx, vec![Event::PointerMoved(from), button(from, true)], time, show);
frame(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], time + 0.1, show);
frame(app, ctx, vec![Event::PointerMoved(to)], time + 0.2, show);
frame(app, ctx, vec![Event::PointerMoved(to), button(to, false)], time + 0.3, show);
}
fn names_of(app: &VectorcraftApp, group: Option<&str>) -> Vec<String> {
let d = &app.session.active().unwrap().doc;
let list = match group {
Some(g) => &d.swatch_groups.iter().find(|x| x.name == g).unwrap().swatches,
None => &d.swatches,
};
list.iter().map(|w| w.name.clone()).collect()
}
#[test]
fn find_field_filters_and_shift_extends_over_what_is_shown() {
let mut app = app();
let ctx = context();
set_pstate(&ctx, "swatch-show-find", true);
ctx.data_mut(|d| d.insert_temp(find_id(), "BRIGHT".to_string()));
frame(&mut app, &ctx, vec![], 0.0, show);
assert!(ctx.read_response(egui::Id::new(("swatch-tile", "Red"))).is_none(), "Red doesn't match");
click_tile(&mut app, &ctx, "Bright Red", Modifiers::NONE, 1.0);
click_tile(&mut app, &ctx, "Bright Blue", Modifiers::SHIFT, 2.0);
assert_eq!(selected(&ctx), ["Bright Red", "Bright Yellow", "Bright Green", "Bright Blue"]);
// A group whose name matches shows all its swatches.
ctx.data_mut(|d| d.insert_temp(find_id(), "grays".to_string()));
frame(&mut app, &ctx, vec![], 3.0, show);
frame(&mut app, &ctx, vec![], 3.5, show);
assert!(ctx.read_response(egui::Id::new(("swatch-tile", "K=50"))).is_some());
assert!(ctx.read_response(egui::Id::new(("swatch-tile", "Bright Red"))).is_none());
}
#[test]
fn list_view_rows_take_double_clicks_and_folder_clicks_select_the_group() {
let mut app = app();
let ctx = context();
set_pstate(&ctx, "swatch-view", View::SmallList);
double_click_tile(&mut app, &ctx, "Red");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND), "double-click in a list opens Swatch Options");
let d = &app.session.active().unwrap().doc;
let sel = vec!["Brights".to_string()];
assert!(highlighted(d, &sel).contains(&"Bright Violet"), "a selected folder highlights its swatches");
assert_eq!(describe(&Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), true, false), "Global Process Color, CMYK");
assert_eq!(describe(&Paint::solid(Color::gray(0.5)), true, true), "Spot Color, Grayscale");
}
#[test]
fn dragging_tiles_reorders_and_moves_into_groups_as_one_undo_step_each() {
let mut app = app();
let ctx = context();
frame(&mut app, &ctx, vec![], 0.0, show);
let undo = app.session.doc().unwrap().history.undo.len();
// Onto the left half of Red: before it.
let (from, red) = (tile_center(&ctx, "Amber"), tile_center(&ctx, "Red"));
drag(&mut app, &ctx, from, red - vec2(4.0, 0.0), 1.0);
let n = names_of(&app, None);
assert_eq!(n[n.iter().position(|x| x == "Red").unwrap() - 1], "Amber");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
// Onto a folder: into that group, at its end. (Two frames, so tile rects are current.)
frame(&mut app, &ctx, vec![], 2.0, show);
frame(&mut app, &ctx, vec![], 2.5, show);
drag(&mut app, &ctx, tile_center(&ctx, "Amber"), tile_center(&ctx, "Grays"), 3.0);
assert_eq!(names_of(&app, Some("Grays")).last().map(String::as_str), Some("Amber"));
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2);
// A selection drags together, in panel order; a gradient can't join a group.
set_pstate(&ctx, "swatch-selected", vec!["Lime".to_string(), "Sunset".to_string(), "Orange".to_string()]);
frame(&mut app, &ctx, vec![], 4.0, show);
frame(&mut app, &ctx, vec![], 4.5, show);
drag(&mut app, &ctx, tile_center(&ctx, "Lime"), tile_center(&ctx, "Bright Red") - vec2(4.0, 0.0), 5.0);
assert_eq!(names_of(&app, Some("Brights"))[..3], ["Orange", "Lime", "Bright Red"]);
assert!(names_of(&app, None).contains(&"Sunset".to_string()));
// Dropped back on itself: nothing moves.
let (before, undo) = (names_of(&app, None), app.session.doc().unwrap().history.undo.len());
frame(&mut app, &ctx, vec![], 6.0, show);
frame(&mut app, &ctx, vec![], 6.5, show);
let white = tile_center(&ctx, "White");
drag(&mut app, &ctx, white, white + vec2(2.0, 0.0), 7.0);
assert_eq!((names_of(&app, None), app.session.doc().unwrap().history.undo.len()), (before, undo));
}
#[test]
fn a_proxy_dropped_on_the_panel_becomes_a_swatch() {
let mut app = app();
let ctx = context();
frame(&mut app, &ctx, vec![], 0.0, show);
let at = tile_center(&ctx, "Bright Red");
egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(Paint::solid(Color::from_hex("#123456").unwrap())));
let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE };
frame(&mut app, &ctx, vec![Event::PointerMoved(at), release], 1.0, show);
assert_eq!(
names_of(&app, Some("Brights")).last().map(String::as_str),
Some("R=18 G=52 B=86"),
"dropped on a group's colour: into that group"
);
assert!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).is_none());
}
#[test]
fn menu_targets_follow_the_panel_selection() {
let app = app();
let sel: Vec<String> = ["Red", "Sunset", "Grays", "Orange", "Brights"].map(String::from).to_vec();
assert_eq!(mergeable(&app, &sel), ["Red", "Orange"], "Merge Swatches takes the selected colours, first kept");
assert_eq!(selected_group(&app, &sel).as_deref(), Some("Grays"));
assert_eq!(selected_group(&app, &sel[..2]), None);
}
#[test] #[test]
fn kind_filter() { fn kind_filter() {
let solid = Paint::solid(Color::BLACK); let solid = Paint::solid(Color::BLACK);

View File

@ -6,7 +6,7 @@ use vectorcraft_engine::Session;
use super::*; use super::*;
fn app_with_rect() -> VectorcraftApp { pub(super) fn app_with_rect() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default()); let mut app = VectorcraftApp::new(Session::new(), Default::default());
run(&mut app, "file.new", json!({"width": 200, "height": 200})); run(&mut app, "file.new", json!({"width": 200, "height": 200}));
let id = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100}))["id"].clone(); let id = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100}))["id"].clone();
@ -14,7 +14,7 @@ fn app_with_rect() -> VectorcraftApp {
app app
} }
fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value { pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
app.session.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) app.session.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
} }
@ -29,13 +29,23 @@ fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, f: impl FnMut(&mut Ve
} }
/// One headless frame with input `events`: every text drawn, with its screen rect. /// One headless frame with input `events`: every text drawn, with its screen rect.
fn frame_events( pub(super) fn frame_events(
ctx: &egui::Context, ctx: &egui::Context,
app: &mut VectorcraftApp, app: &mut VectorcraftApp,
events: Vec<egui::Event>, events: Vec<egui::Event>,
f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui),
) -> Vec<(String, egui::Rect)> {
frame_raw(ctx, app, egui::RawInput { events, ..Default::default() }, f)
}
/// One headless frame with input `raw`: every text drawn, with its screen rect.
pub(super) fn frame_raw(
ctx: &egui::Context,
app: &mut VectorcraftApp,
raw: egui::RawInput,
mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui), mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui),
) -> Vec<(String, egui::Rect)> { ) -> Vec<(String, egui::Rect)> {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| f(app, ui)); let mut out = ctx.run_ui(raw, |ui| f(app, ui));
out.textures_delta.clear(); out.textures_delta.clear();
fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
match s { match s {
@ -52,7 +62,7 @@ fn frame_events(
} }
/// Click at `pos` over three frames of `f` (hover, press, release). /// Click at `pos` over three frames of `f` (hover, press, release).
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) { pub(super) fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) {
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for e in [egui::Event::PointerMoved(pos), button(true), button(false)] { for e in [egui::Event::PointerMoved(pos), button(true), button(false)] {
frame_events(ctx, app, vec![e], &mut f); frame_events(ctx, app, vec![e], &mut f);
@ -60,7 +70,7 @@ fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f:
} }
/// The rect of the first drawn text equal to `s`. /// The rect of the first drawn text equal to `s`.
fn text_rect(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect { pub(super) fn text_rect(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect {
texts.iter().find(|(t, _)| t == s).unwrap_or_else(|| panic!("`{s}` not drawn: {texts:?}")).1 texts.iter().find(|(t, _)| t == s).unwrap_or_else(|| panic!("`{s}` not drawn: {texts:?}")).1
} }

View File

@ -0,0 +1,196 @@
//! Headless frames of the Appearance panel's editing depth: effect names open their dialog
//! prefilled, rows drag (Alt copies), several fills/strokes select together, stroke notes, the
//! chip's Shift-click, the thumbnail drag and the Properties fx button.
use serde_json::{Value, json};
use super::tests_appearance::{app_with_rect, click, frame_events, frame_raw, run, text_rect};
use super::*;
fn pointer(pos: egui::Pos2, pressed: bool, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }
}
/// One frame of the Appearance panel with `events` and `modifiers` held.
fn panel_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>, modifiers: egui::Modifiers) -> Vec<(String, egui::Rect)> {
let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect();
frame_raw(ctx, app, egui::RawInput { events, ..Default::default() }, appearance::show)
}
/// Drag from `from` to `to` in the Appearance panel, `modifiers` held on release.
fn drag(ctx: &egui::Context, app: &mut VectorcraftApp, from: egui::Pos2, to: egui::Pos2, modifiers: egui::Modifiers) {
let none = egui::Modifiers::NONE;
panel_frame(ctx, app, vec![egui::Event::PointerMoved(from)], none);
panel_frame(ctx, app, vec![pointer(from, true, none)], none);
panel_frame(ctx, app, vec![egui::Event::PointerMoved(from + egui::vec2(0.0, 8.0))], none);
panel_frame(ctx, app, vec![egui::Event::PointerMoved(to)], modifiers);
panel_frame(ctx, app, vec![pointer(to, false, modifiers)], modifiers);
panel_frame(ctx, app, vec![], none);
}
/// Click `pos` with `modifiers` held.
fn click_with(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, modifiers: egui::Modifiers) {
for e in [egui::Event::PointerMoved(pos), pointer(pos, true, modifiers), pointer(pos, false, modifiers)] {
panel_frame(ctx, app, vec![e], modifiers);
}
}
/// A point in the row of the label `s` (right of its chip, weight and notes).
fn row_of(texts: &[(String, egui::Rect)], s: &str) -> egui::Pos2 {
let r = text_rect(texts, s);
egui::pos2(r.left() + 200.0, r.center().y)
}
/// The rect of the panel-menu entry `s` (entries are drawn after a check-mark column).
fn menu_item(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect {
texts.iter().find(|(t, _)| t.trim_start() == s).unwrap_or_else(|| panic!("`{s}` not in the menu: {texts:?}")).1
}
fn effects(app: &VectorcraftApp, item: Option<usize>) -> Vec<vectorcraft_doc::Effect> {
first_selected(app).unwrap().appearance.effects_at(item).cloned().unwrap_or_default()
}
#[test]
fn an_effect_name_opens_its_dialog_prefilled_and_ok_edits_it_in_place() {
let mut app = app_with_rect();
run(&mut app, "effect.apply", json!({"effect": "distort.roughen", "params": {"size": 9}}));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
click(&ctx, &mut app, text_rect(&texts, "Roughen").center(), appearance::show);
let d = app.ui.dialog.clone().expect("the effect's dialog");
assert_eq!((d.kind.as_str(), d.f64("size", 0.0), d.fields["__index"].clone()), ("effect", 9.0, json!(0)));
// The dialog previews; OK keeps the new value on the same effect as one undo step.
let mut out = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
out.textures_delta.clear();
app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!(20));
crate::dialogs::confirm(&mut app).unwrap();
let fx = effects(&app, None);
assert_eq!((fx.len(), fx[0].params["size"].clone()), (1, json!(20)));
assert!(app.ui.dialog.is_none() && app.last_effect.is_none(), "an edit is not a new Last Effect");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(effects(&app, None)[0].params["size"], json!(9));
}
#[test]
fn choosing_an_applied_effect_asks_to_edit_it_or_add_another() {
let mut app = app_with_rect();
run(&mut app, "effect.apply", json!({"effect": "distort.roughen", "item": 0, "params": {"size": 9}}));
run(&mut app, "appearance.setActiveItem", json!({"index": 0}));
// The active fill already has it: the question comes first; OK edits the applied one.
assert_eq!(app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap(), json!({"pending": "effectExists"}));
crate::dialogs::confirm(&mut app).unwrap();
let d = app.ui.dialog.clone().unwrap();
assert_eq!((d.kind.as_str(), d.f64("size", 0.0), d.fields["__index"].clone(), d.fields["__item"].clone()), ("effect", 9.0, json!(0), json!(0)));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(effects(&app, Some(0)).len(), 1);
// "Add New": a fresh dialog with the defaults, which adds a second one.
app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
crate::dialogs::confirm(&mut app).unwrap();
let d = app.ui.dialog.clone().unwrap();
assert!(d.fields.get("__index").is_none() && d.f64("size", 0.0) == 5.0, "{:?}", d.fields);
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(effects(&app, Some(0)).len(), 2);
// Another list without it opens the dialog directly.
assert_eq!(app.run("effect.dialog", json!({"effect": "distort.roughen", "item": null})).unwrap(), Value::Null);
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, "effect");
}
#[test]
fn effect_rows_drag_to_reorder_and_into_an_item() {
let mut app = app_with_rect();
run(&mut app, "effect.apply", json!({"effect": "distort.twist"}));
run(&mut app, "effect.apply", json!({"effect": "distort.roughen"}));
let ctx = egui::Context::default();
let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
// Twist below Roughen.
let below = text_rect(&texts, "Roughen").center() + egui::vec2(60.0, 9.0);
drag(&ctx, &mut app, row_of(&texts, "Twist"), below, egui::Modifiers::NONE);
let ids = |fx: Vec<vectorcraft_doc::Effect>| fx.into_iter().map(|e| e.id).collect::<Vec<_>>();
assert_eq!(ids(effects(&app, None)), ["distort.roughen", "distort.twist"]);
// Alt-dragging Twist onto the stroke row copies it into the stroke's effects.
let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
drag(&ctx, &mut app, row_of(&texts, "Twist"), row_of(&texts, "Stroke:"), egui::Modifiers::ALT);
assert_eq!(ids(effects(&app, None)), ["distort.roughen", "distort.twist"]);
assert_eq!(ids(effects(&app, Some(1))), ["distort.twist"]);
// Dropped effects become the selected row (their item active).
assert_eq!(app.session.appearance_item(), Some(1));
app.run("edit.undo", json!({})).unwrap();
assert!(effects(&app, Some(1)).is_empty());
}
#[test]
fn alt_dragging_an_item_duplicates_it_and_several_items_delete_together() {
let mut app = app_with_rect(); // [Fill, Stroke]
let ctx = egui::Context::default();
let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
drag(&ctx, &mut app, row_of(&texts, "Fill:"), row_of(&texts, "Stroke:"), egui::Modifiers::ALT);
let kinds = |app: &VectorcraftApp| first_selected(app).unwrap().appearance.items.iter().map(|i| i.kind_name()).collect::<Vec<_>>();
assert_eq!(kinds(&app), ["fill", "stroke", "fill"]);
// Click the stroke, Cmd-click the top fill: both go with Remove Item.
let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
click_with(&ctx, &mut app, row_of(&texts, "Stroke:"), egui::Modifiers::NONE);
let top_fill = texts.iter().filter(|(t, _)| t == "Fill:").map(|(_, r)| *r).min_by(|a, b| a.top().total_cmp(&b.top())).unwrap();
click_with(&ctx, &mut app, egui::pos2(top_fill.left() + 200.0, top_fill.center().y), egui::Modifiers::COMMAND);
let menu = frame_events(&ctx, &mut app, vec![], appearance::menu);
click(&ctx, &mut app, menu_item(&menu, "Remove Item").center(), appearance::menu);
assert_eq!(kinds(&app), ["fill"]);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(kinds(&app).len(), 3, "one undo step");
}
#[test]
fn stroke_rows_note_dashes_brushes_and_show_all_hidden_attributes() {
let mut app = app_with_rect();
let ctx = egui::Context::default();
assert!(!frame_events(&ctx, &mut app, vec![], appearance::show).iter().any(|(t, _)| t == "Dashed"));
run(&mut app, "stroke.set", json!({"dash": [4, 2], "profile": "lens"}));
let brush = run(&mut app, "brush.list", json!({}))["brushes"][0]["name"].as_str().unwrap().to_string();
run(&mut app, "brush.apply", json!({ "name": brush }));
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
assert!(texts.iter().any(|(t, _)| *t == format!("{brush}, Dashed")), "{texts:?}");
// Show All Hidden Attributes is enabled once something is hidden, and shows it.
let enabled = |app: &mut VectorcraftApp| {
let menu = frame_events(&ctx, app, vec![], appearance::menu);
let r = menu_item(&menu, "Show All Hidden Attributes");
click(&ctx, app, r.center(), appearance::menu);
};
run(&mut app, "appearance.setItem", json!({"index": 0, "visible": false}));
enabled(&mut app);
assert!(first_selected(&app).unwrap().appearance.items[0].visible());
}
#[test]
fn shift_clicking_a_chip_opens_the_mixer_on_that_row() {
let mut app = app_with_rect();
let ctx = egui::Context::default();
let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
// The chip sits 52 pt right of the label's start (label at EYE_W + 24, chip at EYE_W + 76).
let label = text_rect(&texts, "Fill:");
click_with(&ctx, &mut app, egui::pos2(label.left() + 52.0 + 9.0, label.center().y), egui::Modifiers::SHIFT);
assert_eq!(app.ui.open_panel.as_deref(), Some("color"));
assert_eq!(app.session.appearance_item(), Some(0));
}
#[test]
fn the_thumbnail_drags_the_appearance_and_the_properties_fx_button_opens_the_effects() {
let mut app = app_with_rect();
let ctx = egui::Context::default();
let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
// The thumbnail is left of the object row's label.
let label = text_rect(&texts, "Rectangle");
let thumb = egui::pos2(label.left() - 28.0, label.center().y);
let none = egui::Modifiers::NONE;
for e in [egui::Event::PointerMoved(thumb), pointer(thumb, true, none), egui::Event::PointerMoved(thumb + egui::vec2(30.0, 30.0))] {
panel_frame(&ctx, &mut app, vec![e], none);
}
let id = first_selected(&app).unwrap().id;
assert_eq!(egui::DragAndDrop::payload::<crate::widgets::PanelDrag>(&ctx).as_deref(), Some(&crate::widgets::PanelDrag::Appearance(id)));
// Properties: the fx button lists the effect groups.
let props = egui::Context::default();
let texts = frame_raw(&props, &mut app, Default::default(), properties::show);
click(&props, &mut app, text_rect(&texts, "fx").center(), properties::show);
let texts = frame_raw(&props, &mut app, Default::default(), properties::show);
assert!(texts.iter().any(|(t, _)| t == "Distort & Transform"), "{texts:?}");
}

View File

@ -0,0 +1,25 @@
//! Headless frames of the Stroke panel's Dashed Line section.
use serde_json::json;
use super::tests_appearance::{app_with_rect, click, frame_events, run, text_rect};
use super::*;
/// Click the Dashed Line check box and return the stroke's dash afterwards.
fn toggle_dashes(ctx: &egui::Context, app: &mut VectorcraftApp) -> Option<vectorcraft_doc::Dash> {
let at = text_rect(&frame_events(ctx, app, vec![], stroke::show), "Dashed Line").center();
click(ctx, app, at, stroke::show);
current_stroke(app).unwrap().dash
}
#[test]
fn turning_dashes_on_fits_them_to_corners_and_turning_back_on_restores_the_last_ones() {
let (ctx, mut app) = (egui::Context::default(), app_with_rect());
let d = toggle_dashes(&ctx, &mut app).expect("dashes on");
assert_eq!((d.pattern, d.align_corners), (vec![12.0, 12.0], true));
// Exact 4/2 dashes turned off and on again come back as they were.
run(&mut app, "stroke.set", json!({"dash": [4, 2], "alignDashes": false}));
assert!(toggle_dashes(&ctx, &mut app).is_none());
let d = toggle_dashes(&ctx, &mut app).expect("dashes on");
assert_eq!((d.pattern, d.align_corners), (vec![4.0, 2.0], false));
}

View File

@ -1,11 +1,12 @@
//! Transparency panel: blend mode, opacity (field + slider popup), object/mask thumbnails with the //! Transparency panel: blend mode, opacity (field + slider popup), object/mask thumbnails with the
//! opacity-mask controls (make/release, link, clip, invert; Shift-click the mask to disable it), //! opacity-mask controls (make/release, link, clip, invert; Shift-click the mask to disable it),
//! Isolate Blending and Knockout Group. //! Isolate Blending, the three-state Knockout Group (on → neutral → off) and Opacity & Mask Define
//! Knockout Shape; the menu's Page Isolated Blending and Page Knockout Group.
use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; use egui::{Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json; use serde_json::json;
use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Node, NodeId, OpacityMask}; use vectorcraft_doc::{Knockout, Node, NodeId, OpacityMask};
use vectorcraft_engine::cmd::TransparencyInfo; use vectorcraft_engine::cmd::TransparencyInfo;
use super::{current_paints, current_transparency, live_run, pstate, selection_len, set_pstate}; use super::{current_paints, current_transparency, live_run, pstate, selection_len, set_pstate};
@ -40,7 +41,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let editing = app.session.active().and_then(|d| d.doc.mask_edit); let editing = app.session.active().and_then(|d| d.doc.mask_edit);
let (info, n) = state(app, ui.ctx()); let (info, n) = state(app, ui.ctx());
let has = n.is_some(); let has = n.is_some();
let (isolate, knockout) = (info.isolate.unwrap_or(false), info.knockout.unwrap_or(false)); let (isolate, knockout_shape) = (info.isolate.unwrap_or(false), info.knockout_shape.unwrap_or(false));
// Opacity and blend of the Appearance panel's active item (not while a mask is edited: the // Opacity and blend of the Appearance panel's active item (not while a mask is edited: the
// panel then shows the masked object), else of the targets, blank where they differ; with no // panel then shows the masked object), else of the targets, blank where they differ; with no
// target, the defaults for new art. // target, the defaults for new art.
@ -152,10 +153,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if widgets::check(ui, "Isolate Blending", isolate, has) { if widgets::check(ui, "Isolate Blending", isolate, has) {
app.run("transparency.set", json!({"isolate": !isolate})).ok(); app.run("transparency.set", json!({"isolate": !isolate})).ok();
} }
if widgets::check(ui, "Knockout Group", knockout, has) { // Neutral (and mixed) show a dash; a click moves on → neutral → off → on (mixed → on).
app.run("transparency.set", json!({"knockout": !knockout})).ok(); let shown = match info.knockout {
Some(Knockout::On) => Some(true),
Some(Knockout::Off) => Some(false),
_ => None,
};
if widgets::check3(ui, "Knockout Group", shown, has) {
let next = info.knockout.map_or(Knockout::On, Knockout::cycle);
app.run("transparency.set", json!({"knockout": next.label()})).ok();
}
if widgets::check(ui, "Opacity & Mask Define Knockout Shape", knockout_shape, has) {
app.run("transparency.set", json!({"knockoutShape": !knockout_shape})).ok();
} }
widgets::check(ui, "Opacity & Mask Define Knockout Shape", false, false);
} }
if !has { if !has {
widgets::dim_label(ui, if selection_len(app) == 0 { "No Selection" } else { "" }); widgets::dim_label(ui, if selection_len(app) == 0 { "No Selection" } else { "" });
@ -190,8 +200,14 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("transparency.toggleNewMasksInverted", json!({})).ok(); app.run("transparency.toggleNewMasksInverted", json!({})).ok();
} }
ui.separator(); ui.separator();
menu_item(ui, "Page Isolated Blending", false, false); let page = app.session.active().map(|d| (d.doc.page_isolate, d.doc.page_knockout));
menu_item(ui, "Page Knockout Group", false, false); let (isolate, knockout) = page.unwrap_or_default();
if menu_item(ui, "Page Isolated Blending", page.is_some(), isolate) {
app.run("transparency.togglePageIsolatedBlending", json!({})).ok();
}
if menu_item(ui, "Page Knockout Group", page.is_some(), knockout) {
app.run("transparency.togglePageKnockoutGroup", json!({})).ok();
}
} }
/// The menu's opacity-mask rows (label, command, enabled) for the target's `mask`; Make needs a /// The menu's opacity-mask rows (label, command, enabled) for the target's `mask`; Make needs a
@ -346,4 +362,21 @@ mod tests {
frame(&mut app); frame(&mut app);
assert_eq!(app.session.execute("transparency.opacityMaskInfo", &json!({})).unwrap()[0]["disabled"], true); assert_eq!(app.session.execute("transparency.opacityMaskInfo", &json!({})).unwrap()[0]["disabled"], true);
} }
#[test]
fn knockout_states_and_page_group_items() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
for k in ["on", "off", "neutral"] {
run(&mut app, "transparency.set", json!({"knockout": k, "knockoutShape": k == "on"}));
let texts = frame(&mut app);
assert!(texts.iter().any(|t| t == "Knockout Group") && texts.iter().any(|t| t == "Opacity & Mask Define Knockout Shape"));
}
assert!(frame(&mut app).iter().any(|t| t.ends_with(" Page Knockout Group") && !t.starts_with('✓')));
run(&mut app, "transparency.togglePageKnockoutGroup", json!({}));
run(&mut app, "transparency.togglePageIsolatedBlending", json!({}));
let texts = frame(&mut app);
assert!(texts.iter().any(|t| t == "✓ Page Knockout Group") && texts.iter().any(|t| t == "✓ Page Isolated Blending"), "{texts:?}");
}
} }

View File

@ -157,6 +157,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} else if resp.clicked() && alt && slot.len() > 1 { } else if resp.clicked() && alt && slot.len() > 1 {
let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0); let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0);
app.select_tool(slot[(idx + 1) % slot.len()]); app.select_tool(slot[(idx + 1) % slot.len()]);
} else if resp.double_clicked() {
app.select_tool(shown.id);
app.run("tool.options", json!({ "tool": shown.id })).ok();
} else if resp.clicked() { } else if resp.clicked() {
app.select_tool(shown.id); app.select_tool(shown.id);
} }
@ -178,6 +181,28 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
flyout(app, ui.ctx()); flyout(app, ui.ctx());
} }
/// Open a tool's options (`tool.options`, a double-click on its button): the Gradient tool's are
/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog.
pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::Value, String> {
match tool {
"gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })),
"gradient" => app.run("window.panel", json!({ "panel": "gradient" })),
"eyedropper" => {
let o = app.session.eyedropper;
let fields = json!({
"__command": "eyedropper.setOptions",
"__label": "Eyedropper Options",
"appearance": o.appearance,
"transparency": o.transparency,
});
app.ui.dialog = Some(crate::state::Dialog::new("command", fields));
Ok(json!({ "dialog": "command" }))
}
_ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")),
_ => Err(format!("the {tool} tool has no options")),
}
}
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) { fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0)); ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0));
ui.add_space(5.0); ui.add_space(5.0);
@ -289,3 +314,59 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
} }
let _ = theme::semibold; let _ = theme::semibold;
} }
#[cfg(test)]
mod tests {
use egui::{Event, PointerButton, Pos2};
use vectorcraft_engine::Session;
use super::*;
/// One headless frame of the toolbar; returns the tool buttons' rects, top to bottom.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<Event>) -> Vec<egui::Rect> {
let input = egui::RawInput {
time: Some(time),
events,
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))),
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| show(app, ui));
out.textures_delta.clear();
let mut r: Vec<egui::Rect> = ctx.viewport(|vp| {
let size = vec2(36.0, PITCH - 1.0);
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect()
});
r.sort_by(|a, b| a.top().total_cmp(&b.top()));
r
}
/// A double-click at `at`, `time` seconds in (a second apart from the last).
fn double_click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) {
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, ctx, time, vec![Event::PointerMoved(at), b(true), b(false), b(true), b(false)]);
frame(app, ctx, time + 0.1, vec![]);
}
#[test]
fn double_clicking_a_tool_button_opens_its_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let ctx = egui::Context::default();
let buttons = frame(&mut app, &ctx, 0.0, vec![]);
// The Color category's slots close the list: Gradient, then Eyedropper.
let (gradient, eyedropper) = (buttons[buttons.len() - 2], buttons[buttons.len() - 1]);
double_click(&mut app, &ctx, 1.0, gradient.center());
assert_eq!((app.session.tool_id(), app.ui.open_panel.as_deref()), ("gradient", Some("gradient")));
// Again: the panel stays open.
double_click(&mut app, &ctx, 2.0, gradient.center());
assert_eq!(app.ui.open_panel.as_deref(), Some("gradient"));
double_click(&mut app, &ctx, 3.0, eyedropper.center());
let d = app.ui.dialog.clone().expect("Eyedropper Options");
assert_eq!((app.session.tool_id(), d.kind.as_str(), d.str("__command")), ("eyedropper", "command", "eyedropper.setOptions".into()));
// OK applies the options.
app.ui.dialog.as_mut().unwrap().fields.insert("transparency".into(), json!(false));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.session.eyedropper.appearance && !app.session.eyedropper.transparency);
assert!(app.run("tool.options", json!({"tool": "zoom"})).is_err());
}
}

View File

@ -255,8 +255,14 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool
let s = size * 0.62; let s = size * 0.62;
let fill_r = Rect::from_min_size(rect.min + vec2(0.0, 0.0), Vec2::splat(s)); let fill_r = Rect::from_min_size(rect.min + vec2(0.0, 0.0), Vec2::splat(s));
let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s)); let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s));
let stroke_resp = ui.interact(stroke_r, ui.id().with("stroke-proxy"), Sense::click()); let stroke_resp = ui.interact(stroke_r, ui.id().with("stroke-proxy"), Sense::click_and_drag());
let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click()); let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click_and_drag());
// A proxy drags its paint (onto the Swatches panel or art), unless it shows "?".
for (resp, paint, mixed) in [(&fill_resp, fill, mixed.0), (&stroke_resp, stroke, mixed.1)] {
if !mixed {
drag_source(ui, resp, || PanelDrag::paint(paint.clone()));
}
}
let question = |ui: &Ui, r: Rect, color: Color32| { let question = |ui: &Ui, r: Rect, color: Color32| {
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(r.height() * 0.8), color); ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(r.height() * 0.8), color);
}; };
@ -538,14 +544,27 @@ fn choice_row(ui: &mut Ui, label: &str, enabled: bool) -> (Rect, Response, Color
/// Panel-style checkbox with a disabled state. Returns true when toggled. /// Panel-style checkbox with a disabled state. Returns true when toggled.
pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool {
check3(ui, label, Some(value), enabled)
}
/// Three-state [`check`]: `None` shows a dash (a neutral or mixed state). Returns true when clicked.
pub fn check3(ui: &mut Ui, label: &str, value: Option<bool>, enabled: bool) -> bool {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
let (bx, resp, border) = choice_row(ui, label, enabled); let (bx, resp, border) = choice_row(ui, label, enabled);
ui.painter().rect_filled(bx, CornerRadius::same(2), if value && enabled { t.accent_strong } else { t.input }); let checked = value == Some(true);
ui.painter().rect_filled(bx, CornerRadius::same(2), if checked && enabled { t.accent_strong } else { t.input });
ui.painter().rect_stroke(bx, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside); ui.painter().rect_stroke(bx, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside);
if value { match value {
let c = if enabled { Color32::WHITE } else { t.text_disabled }; Some(true) => {
ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.6, c)); let c = if enabled { Color32::WHITE } else { t.text_disabled };
ui.painter().line_segment([bx.center_bottom() + vec2(-1.0, -3.5), bx.right_top() + vec2(-3.0, 3.0)], Stroke::new(1.6, c)); ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.6, c));
ui.painter().line_segment([bx.center_bottom() + vec2(-1.0, -3.5), bx.right_top() + vec2(-3.0, 3.0)], Stroke::new(1.6, c));
}
None => {
let c = if enabled { t.text } else { t.text_disabled };
ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.right_center() - vec2(3.0, 0.0)], Stroke::new(1.6, c));
}
Some(false) => {}
} }
enabled && resp.clicked() enabled && resp.clicked()
} }
@ -844,6 +863,29 @@ pub fn swatch_tile(ui: &Ui, rect: Rect, paint: &Paint, selected: bool, hovered:
} }
} }
/// A recessed search field with an italic `hint` (Layers' Search All, Swatches' Find), keeping its
/// text in egui memory under `id`. Returns the text.
pub fn search_field(ui: &mut Ui, id: egui::Id, hint: &str) -> String {
let t = Tokens::get(ui.ctx());
let mut query: String = ui.data(|d| d.get_temp(id)).unwrap_or_default();
egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, t.input_border))
.corner_radius(egui::CornerRadius::same(2))
.inner_margin(egui::Margin::symmetric(8, 5))
.show(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut query)
.id(id.with("edit"))
.frame(egui::Frame::NONE)
.hint_text(egui::RichText::new(hint).italics())
.desired_width(ui.available_width()),
);
});
ui.data_mut(|d| d.insert_temp(id, query.clone()));
query
}
/// A bordered list box (Swatches tiles, Appearance rows, Artboards list). /// A bordered list box (Swatches tiles, Appearance rows, Artboards list).
pub fn list_box<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { pub fn list_box<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
@ -902,3 +944,56 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
None None
} }
} }
/// What panels drag onto art and onto each other. One payload type, so the canvas has one drop
/// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer.
#[derive(Clone, Debug, PartialEq)]
pub enum PanelDrag {
/// A paint: Swatches panel tiles, a Fill/Stroke proxy or the Gradient panel's thumbnail. Art it
/// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group,
/// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the
/// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes
/// a swatch of a paint dragged from elsewhere.
Paint { paint: Paint, params: serde_json::Value, rows: Option<SwatchRows> },
/// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance
/// (`appearance.copyFrom`).
Appearance(vectorcraft_doc::NodeId),
}
/// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour
/// group under the pointer when the drag started (`grabbed`) and `names`, the panel selection when
/// the grabbed one is part of it (only colour groups, or only swatches, like the grabbed one), in
/// panel order.
#[derive(Clone, Debug, PartialEq)]
pub struct SwatchRows {
pub grabbed: String,
pub names: Vec<String>,
/// The names are colour groups.
pub groups: bool,
}
impl PanelDrag {
/// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient
/// carries no placement: it fits the art it lands on.
pub fn paint(mut paint: Paint) -> Self {
if let Paint::Gradient(g) = &mut paint {
g.geom = None;
}
Self::Paint { params: crate::panels::paint_params(&paint), paint, rows: None }
}
/// The dragged colour (a solid paint's), which the Gradient panel's ramp takes.
pub fn color(&self) -> Option<vectorcraft_color::Color> {
match self {
Self::Paint { paint, .. } => paint.color(),
Self::Appearance(_) => None,
}
}
}
/// Make `resp` (which senses drags) a source of the [`PanelDrag`] `drag` builds when a drag starts
/// on it.
pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) {
if resp.drag_started() {
egui::DragAndDrop::set_payload(ui.ctx(), drag());
}
}

View File

@ -36,3 +36,27 @@ fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern
`swatches`); `ui.dialog.confirm` runs `swatch.new` / `swatch.newGroup`. `swatches`); `ui.dialog.confirm` runs `swatch.new` / `swatch.newGroup`.
The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`. The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`.
Graphic styles: `ui.graphicStyleOptions {name}` opens the `graphicStyleOptions` dialog (field `name`) for a style and
`ui.dialog.confirm` renames it with `graphicStyle.rename` (a name another style has returns an error and keeps the
dialog open); without `name` it names a new style made from the selection (`graphicStyle.new`).
Gradient panel: double-clicking a stop on the panel's slider opens the same `gradientStop` popover, with a `screen`
field (`[x, y]`, screen points) in place of `x`/`y`. The panel's stop eyedropper selects the Eyedropper tool with the
tool option `stop` (the tool to return to; `tool.setOption {key: "stop", value: "gradient"}`): its next click on art
samples the colour there into the selected stop (`paint.sampleColor {color, stop}`) and switches back. Dragging a
swatch, a Fill/Stroke proxy or the panel's gradient thumbnail onto art runs `paint.setFill` / `paint.setStroke` (the
active proxy) with the paint's params and the object's `ids`; a colour dropped on the panel's ramp adds or recolours
a stop.
Tool options: double-clicking a tool button runs `tool.options {tool}`. For `gradient` it opens the Gradient panel;
for `eyedropper` it opens Eyedropper Options, a `command` dialog (fields `appearance`, `transparency`) whose
`ui.dialog.confirm` runs `eyedropper.setOptions` (what `appearance.copyFrom` copies). Gradient tool handles snap to
anchors, edges and smart guides; Shift constrains them to 45° steps from the `constrainAngle` preference.
Effect dialogs: `engine.execute {command: "effect.dialog", params: {effect, index?, item?}}` opens the `effect` dialog
(fields: the effect's parameters, `preview`). With `index` it edits that applied effect of `item` (null: the object's
effects) prefilled with its values, and `ui.dialog.confirm` runs `effect.setParams`; otherwise confirm runs
`effect.apply`. Choosing an effect that the list already has returns `{"pending": "effectExists"}` and opens the
`effectExists` question: `ui.dialog.confirm` opens the applied effect's dialog, `ui.dialog.set {field: "discard",
value: true}` then confirm opens a fresh one that adds another, `ui.dialog.cancel` drops it.

View File

@ -94,6 +94,16 @@ swatches and colour groups in one undo step (art using a deleted global swatch k
`swatch.new {color?, mode?, spot?, global?, group?}` saves a colour (into a colour group with `group`), gradient `swatch.new {color?, mode?, spot?, global?, group?}` saves a colour (into a colour group with `group`), gradient
or pattern; `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected or pattern; `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected
art's colours (by default as global swatches the art links to). art's colours (by default as global swatches the art links to).
`swatch.move {names, to?, group?}` reorders swatches or moves them into or out of a colour group (as dragging
them in the Swatches panel does); give colour group names instead to reorder the groups. Dropping a swatch on art
in the app runs `paint.setFill` (or `paint.setStroke`, the active proxy) with `ids: [the object under the pointer]`.
The Swatches panel menu's commands: `swatch.addUsedColors {selection?, global?}`, `swatch.unused` (a query: the
names Select All Unused selects), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and
`swatch.sortByKind`.
Document colour mode: `file.new {colorMode: "cmyk"}` starts a CMYK document with CMYK default swatches, and RGB
colours applied to it (`paint.setFill`/`setStroke` colours and gradient stops, `swatch.new`) are stored as CMYK;
Gray stays Gray, and `keepModel: true` keeps a colour as given. RGB documents keep colours as given. Harmonies, Edit
Colors blends, inversions and Recolor Artwork keep each colour's model (a blend between models takes the document's).
## Resources ## Resources
@ -185,6 +195,14 @@ until `transparency.stopEditingOpacityMask`. Saves and exports never include the
{"name":"run_command","arguments":{"command":"transparency.info","params":{"ids":[12,20]}}} {"name":"run_command","arguments":{"command":"transparency.info","params":{"ids":[12,20]}}}
``` ```
Knockout Group has three states: `transparency.set {knockout: "on"|"off"|"neutral"}` (`true` = on, `false` =
neutral, the default). In a knockout group each child hides what it covers of the children below it; neutral groups
pass the enclosing group's setting to their children, off groups never knock out. `knockoutShape: true` makes the
object's opacity and opacity mask scale how much it knocks out. `transparency.togglePageKnockoutGroup` and
`transparency.togglePageIsolatedBlending` (`{value?}`) treat the whole page as a knockout or isolated group; they are
saved with the document and undoable, and `transparency.info` reports them. Knockout groups render as isolated groups;
PDF and SVG write them as soft-masked groups with the same look (the PDF export reports a warning).
## Clipping masks ## Clipping masks
`object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a `object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a
@ -196,3 +214,31 @@ plain group and keeps the clipping path, unpainted.
{"name":"run_command","arguments":{"command":"select.set","params":{"ids":[12,15]}}} {"name":"run_command","arguments":{"command":"select.set","params":{"ids":[12,15]}}}
{"name":"run_command","arguments":{"command":"object.clippingMask.make","params":{}}} {"name":"run_command","arguments":{"command":"object.clippingMask.make","params":{}}}
``` ```
## Graphic styles
A graphic style holds an appearance (fills, strokes, effects) plus opacity, blend mode, isolate and knockout.
`graphicStyle.new {name?, id?}` captures an object (a group without its own fills or strokes lends its topmost
object's, type its characters'). `graphicStyle.apply {name, ids?}` gives the objects the style and links them;
`add: true` adds the style on top of the existing appearance instead. Linked objects stay linked while they keep the
style's look: editing their appearance or transparency breaks the link, and `graphicStyle.redefine {name?, id?}`
updates only the objects still linked. `graphicStyle.list` returns each style with the ids linked to it and the
style of the first selected object; `select.same.graphicStyle` selects an object's fellow users.
Styles keep placed gradients relative to the bounds of the object they were made from, and each object a
style is applied to gets them at the same place relative to its own bounds.
```json
{"name":"run_command","arguments":{"command":"graphicStyle.new","params":{"id":12,"name":"Glow"}}}
{"name":"run_command","arguments":{"command":"graphicStyle.apply","params":{"name":"Glow","ids":[15,20]}}}
{"name":"run_command","arguments":{"command":"graphicStyle.redefine","params":{"id":12}}}
{"name":"run_command","arguments":{"command":"graphicStyle.list","params":{}}}
```
## Editing appearance stacks
`effect.move {from, to, fromItem?, toItem?, copy?}` reorders an effect or moves it between the object's effects
(`null`) and a fill's or stroke's (an item index), as dragging its row in the Appearance panel does; `copy: true` copies
it (Alt-drag). `appearance.duplicateItem {index, to?}` / `{indices}` and `appearance.removeItem {index | indices}`
act on several fills/strokes at once, and `appearance.showAllHidden` makes every hidden fill, stroke and effect
visible again. In remote mode, `effect.dialog {effect, index, item}` opens an applied effect's dialog prefilled
(`ui.dialog.confirm` runs `effect.setParams`); see the control protocol for the "already applied" question.