Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59c890df9e | ||
|
|
d4b0a5479b | ||
|
|
7989d1345f | ||
|
|
f443dde16f | ||
|
|
c36b6976e3 | ||
|
|
01e165af63 | ||
|
|
199a464140 | ||
|
|
45516a6a96 | ||
|
|
d0015a496b | ||
|
|
2e07f887bf | ||
|
|
500e19680d | ||
|
|
b3fa281282 | ||
|
|
f2252b0d9c | ||
|
|
06dbe5b1e8 | ||
|
|
5a638b0a80 | ||
|
|
eb0f2ff2e4 | ||
|
|
202894372a | ||
|
|
c89906a13a | ||
|
|
ba2069c057 | ||
|
|
555b0e60d3 | ||
|
|
e3874e89e7 | ||
|
|
8a0cd9dbf6 | ||
|
|
f147eb58c7 | ||
|
|
3a48de555e | ||
|
|
d7fb5ec727 | ||
|
|
6794385731 | ||
|
|
bea68002e9 | ||
|
|
2f97c8ab49 | ||
|
|
2b000d828e | ||
|
|
a644c818c9 | ||
|
|
194968bc27 | ||
|
|
58bb72ffc6 | ||
|
|
6315086f96 | ||
|
|
2e643b0e76 | ||
|
|
bd895a1de7 | ||
|
|
12f8a77a00 | ||
|
|
9f659195c3 | ||
|
|
258d61ced0 | ||
|
|
5ff1b97376 | ||
|
|
9216c47204 | ||
|
|
4eaf5e7b50 | ||
|
|
068a934f6a | ||
|
|
079580257a | ||
|
|
f81c0f51d7 | ||
|
|
01a65c44ab | ||
|
|
11c3a99bfb |
No files matched your search
@@ -5,7 +5,7 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta
|
||||
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
|
||||
|
||||
## Start every session here
|
||||
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
|
||||
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list.
|
||||
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
@@ -56,10 +56,15 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
|
||||
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
|
||||
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low.
|
||||
## Roadmap and progress docs
|
||||
The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md):
|
||||
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
|
||||
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
|
||||
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
|
||||
- Checklists: [`ui-parity.md`](docs/ui-parity.md), [`file-format-parity.md`](docs/file-format-parity.md), [`hardware-parity.md`](docs/hardware-parity.md), [`localization-parity.md`](docs/localization-parity.md), [`effects-parity.md`](docs/effects-parity.md), [`type-parity.md`](docs/type-parity.md); [`architecture.md`](docs/architecture.md).
|
||||
|
||||
When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history.
|
||||
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated.
|
||||
- Keep the README's Status section in step with the ROADMAP headline.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
@@ -5,7 +5,7 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta
|
||||
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
|
||||
|
||||
## Start every session here
|
||||
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
|
||||
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list.
|
||||
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
@@ -56,8 +56,13 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
|
||||
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
|
||||
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low.
|
||||
## Roadmap and progress docs
|
||||
The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md):
|
||||
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
|
||||
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
|
||||
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
|
||||
- Checklists: [`ui-parity.md`](docs/ui-parity.md), [`file-format-parity.md`](docs/file-format-parity.md), [`hardware-parity.md`](docs/hardware-parity.md), [`localization-parity.md`](docs/localization-parity.md), [`effects-parity.md`](docs/effects-parity.md), [`type-parity.md`](docs/type-parity.md); [`architecture.md`](docs/architecture.md).
|
||||
|
||||
When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history.
|
||||
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated.
|
||||
- Keep the README's Status section in step with the ROADMAP headline.
|
||||
Generated
+10
@@ -4335,6 +4335,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
@@ -4448,6 +4449,7 @@ dependencies = [
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-three-d",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4703,6 +4705,13 @@ dependencies = [
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.8.0"
|
||||
@@ -4757,6 +4766,7 @@ dependencies = [
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-three-d",
|
||||
"vectorcraft-tools",
|
||||
"wat",
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@ vectorcraft-brush = { path = "crates/brush" }
|
||||
vectorcraft-trace = { path = "crates/trace" }
|
||||
vectorcraft-text = { path = "crates/text" }
|
||||
vectorcraft-effects = { path = "crates/effects" }
|
||||
vectorcraft-three-d = { path = "crates/three-d" }
|
||||
vectorcraft-render = { path = "crates/render" }
|
||||
vectorcraft-svg = { path = "crates/svg" }
|
||||
vectorcraft-pdf = { path = "crates/pdf" }
|
||||
|
||||
@@ -198,11 +198,14 @@ For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instruc
|
||||
|
||||
## Status
|
||||
|
||||
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
|
||||
milestones, and honest time-to-parity estimates.
|
||||
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the stage, the numbers by dimension
|
||||
and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and
|
||||
[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing.
|
||||
|
||||
**Where we are (2026-10-10):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
|
||||
**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against
|
||||
all of Illustrator): everyday
|
||||
vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
|
||||
@@ -213,23 +216,25 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
|
||||
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
||||
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
||||
The interface
|
||||
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus).
|
||||
The scores are
|
||||
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
|
||||
speaks English, German, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
- 3D and Materials;
|
||||
- the Photoshop-style raster effects (Effect Gallery);
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- Variables and scripting;
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- packaging for Windows and Linux.
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- about half of the Photoshop-style raster effects (29 of 57 still missing) and the Effect Gallery;
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- generative AI features.
|
||||
|
||||
**Where we're going:** next is the interaction-fidelity pass alongside the raster-effects package, then 3D and
|
||||
advanced type, then hardening and packaging for 1.0. The prioritized list is in
|
||||
[Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order).
|
||||
**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability,
|
||||
alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in
|
||||
[`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so
|
||||
the UI can be swapped without touching the engine.
|
||||
|
||||
|
||||
+122
-247
@@ -1,274 +1,149 @@
|
||||
# VectorCraft Roadmap
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux and the web (WASM), and agents can drive it fully over a JSON control channel and MCP.
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
control channel. This page is the summary; the evidence is in [`docs/target-app-parity.md`](docs/target-app-parity.md)
|
||||
and the work list in [`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
## Where we are
|
||||
**Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance,
|
||||
type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's
|
||||
own `.ai` files open with a wrong layer structure in real cases and can't be saved back with Illustrator's
|
||||
editing data, the interaction details have never been checked side by side, and some machines fail to start the
|
||||
app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow
|
||||
gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print
|
||||
production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without
|
||||
blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| Dimension | Status |
|
||||
|---|---|
|
||||
| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** |
|
||||
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
|
||||
| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
|
||||
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** |
|
||||
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~210–330 h** one agent · **~55–95 h** with 4–6 agents |
|
||||
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~320–500 h** one agent · **~80–145 h** with 4–6 agents |
|
||||
## Headline numbers
|
||||
|
||||
## Honest assessment (2026-10-05)
|
||||
|
||||
**In one line:** about two-thirds to three-quarters of Illustrator's features exist and work, and for everyday
|
||||
vector illustration (drawing, paths, paint and appearance, SVG/PDF/print) VectorCraft is close to usable. Measured
|
||||
against "a power user can't tell the difference", it is closer to 40–55%. What stands between here and "anyone can
|
||||
switch" is a handful of large subsystems plus a fidelity pass, not a long tail of small fixes.
|
||||
|
||||
**How much to trust the numbers:** area scores are graded by the agents that built the features, from behaviour docs
|
||||
(`plan/illustrator/`) and tests, not by side-by-side use of Illustrator. Treat them as upper bounds. Look & feel is
|
||||
the only dimension measured against the reference app. Performance budgets haven't been re-run on an idle machine
|
||||
since 2026-10-01.
|
||||
|
||||
### By dimension
|
||||
|
||||
| Dimension | Estimate | Evidence and what's missing |
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~16 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) |
|
||||
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 28/57, 3D 1/4 (initial Revolve) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
**Readiness by audience** (all estimated; method in [target-app-parity.md](docs/target-app-parity.md#ready-for-real-work)):
|
||||
|
||||
Ordered by how much each gap blocks someone from switching. Sizes are one-agent hours from the parity table.
|
||||
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|
||||
|---|---:|---:|---:|---|
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (24–40 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem |
|
||||
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
|
||||
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
|
||||
|
||||
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
|
||||
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
|
||||
isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h.
|
||||
2. **Photoshop-style raster effects and the Effect Gallery:** about 39 filters (Artistic, Brush Strokes, Distort,
|
||||
Sketch, Stylize) and the Effect Gallery. The pixel-filter pipeline is in place
|
||||
(`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and
|
||||
Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur
|
||||
(Normal mode only), Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask,
|
||||
Stylize › Glowing Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (its
|
||||
Brick, Burlap, Canvas and Sandstone surfaces made in code; Load Texture is missing) and Video › De-Interlace and
|
||||
NTSC Colors; each new filter is now one function, a catalogue row, menu and translations. Parallelizes
|
||||
well across agents. 20–33 h.
|
||||
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
|
||||
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
|
||||
The largest single gap. 50–80 h.
|
||||
4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, proportional vertical metrics, manual
|
||||
tate-chu-yoko), Optical Margin Alignment, the hyphenation and justification options (the Every-line/Single-line
|
||||
composer choice is done), tab leaders, a
|
||||
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
|
||||
initial support. 34–47 h across type core and advanced.
|
||||
5. **Brushes, symbols and libraries:** brush options depth (Brush Options exist for every brush type, Calligraphic
|
||||
and Scatter with pen pressure; still missing are the Art brush's Overlap, auto-generated Pattern brush corners,
|
||||
new tile art picked in Pattern Brush Options and the Key Color eyedropper); original brush, symbol and
|
||||
graphic-style libraries generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by
|
||||
pen tilt, bearing and barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every
|
||||
platform. 17–28 h.
|
||||
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
|
||||
rulers. 15–25 h.
|
||||
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, Affinity files from more versions and platforms
|
||||
(51 pinned files now include 28 original Affinity 3.2.3 feature probes; large interacting documents,
|
||||
complex typography and independently paired `.af`/PSD renders reopened in Affinity remain missing), idle-machine perf budgets, Windows, Linux and
|
||||
browser QA, pen tablets on Wayland and pen pressure beyond Windows (winit 0.31's tablet input, #491), accessibility, packaging. 50–80 h.
|
||||
Hours are Opus 5.5 agent wall-clock hours, calibrated from this repo's merged-PR timestamps (e.g. 0.7–1.2 h per
|
||||
Photoshop-style filter, ~0.8 h per medium feature); see [calibration](docs/target-app-parity.md#calibration).
|
||||
The installed Illustrator was **not** inspected: the clean-room rule in [`AGENTS.md`](AGENTS.md) forbids it, so
|
||||
everything is measured from our code against Illustrator's public documentation.
|
||||
|
||||
### Where we're going
|
||||
## By dimension
|
||||
|
||||
- **Next:** start the interaction-fidelity pass (1) in parallel with the raster-effects package (2). Both are broad,
|
||||
so they split well across agents, and they move the "can't tell the difference" number most.
|
||||
- **Then:** 3D (3) and advanced type (4), each a focused milestone with its own plan in `plan/` before coding.
|
||||
- **Alongside:** keep closing the stubbed menu items. Run `vectorcraft-cli perf` on an idle machine, and fix any
|
||||
budget it misses before 1.0.
|
||||
- **1.0:** feature parity plus the fidelity pass, hardening (8) and packaging for all three desktop OSes and the web.
|
||||
| Dimension | % | One agent (h) | Doc |
|
||||
|---|---:|---:|---|
|
||||
| Features (depth) | 75% | 220–360 | [target-app-parity.md](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization (12 key languages) | 54% (measured) | 70–110 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) |
|
||||
| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) |
|
||||
| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) |
|
||||
| Ecosystem and plug-ins | 15% | 30–60 | [gaps.md › G17](docs/gaps.md#g17-ecosystem-third-party-plug-ins) |
|
||||
| AI features (generative) | 0% | 30–60 + owner decision | [gaps.md › G18](docs/gaps.md#g18-generative-ai-features) |
|
||||
| Agent automation | beyond Illustrator | — | [mcp.md](docs/mcp.md) |
|
||||
|
||||
**For agents:** pick work from the list above (or the milestone rows below), and write a task plan before coding.
|
||||
When a task lands, update this section, the parity table and "Shipped so far" in the same PR. Keep the scores honest:
|
||||
grade by behaviour against `plan/illustrator/`, not by whether a menu item exists.
|
||||
The dimension hours overlap (feature rows hold some UI, format and AI work), so they don't add up to the total.
|
||||
|
||||
## Shipped so far
|
||||
## Features
|
||||
|
||||
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
|
||||
- **Automation:**
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832).
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it).
|
||||
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
|
||||
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
|
||||
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
|
||||
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
|
||||
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). About 17 stored options still do nothing yet (the rest of #394: Highlight Alternate Glyphs, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
|
||||
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
|
||||
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
|
||||
- The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box.
|
||||
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
|
||||
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected).
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
- **Tools:**
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784).
|
||||
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
|
||||
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come). A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph Design, Column and Marker artwork remain open.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel.
|
||||
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them).
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and Line truncated
|
||||
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
|
||||
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
|
||||
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`).
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [scope, provenance and limits](crates/affinity/README.md).
|
||||
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
|
||||
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
| Area | % | One agent (h) |
|
||||
|---|---:|---:|
|
||||
| Selection, transform & align | 85% | 4–6 |
|
||||
| Drawing tools | 85% | 8–13 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 80% | 4–8 |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 92% | 4–7 |
|
||||
| Strokes, brushes, width profiles | 88% | 8–15 |
|
||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||
| Live vector effects | 85% | 6–10 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 48% | 24–40 |
|
||||
| 3D and Materials | ~8% | 50–85 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 |
|
||||
| Image Trace, graphs, image tools | 75% | 5–8 |
|
||||
| Layers, artboards, document setup | 78% | 5–8 |
|
||||
| View & navigation | 70% | 9–14 |
|
||||
| Guides, grids, smart guides, snapping | 80% | 4–7 |
|
||||
| File formats — [file-format-parity.md](docs/file-format-parity.md) | 75% | 10–20 |
|
||||
| Export for Screens, Asset Export, slices | 85% | 2–4 |
|
||||
| Print, colour management, separations | 80% | 2–4 |
|
||||
| Automation (Actions, Variables, scripting) | 60% | 8–14 |
|
||||
| UI chrome (panels, Properties, preferences) | 80% | 13–20 |
|
||||
| Libraries, Links, Package | 82% | 2–3 |
|
||||
| Generative AI | 0% | 30–60 |
|
||||
|
||||
## Milestones and estimates
|
||||
Weights, evidence and the missing items per area: [target-app-parity.md](docs/target-app-parity.md#feature-areas).
|
||||
|
||||
| # | Milestone | Status | Est. remaining, one agent (h) |
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; graph Design/Column/Marker designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
|
||||
## Languages
|
||||
|
||||
## Parity estimate
|
||||
| Language | Code | Status | Translated |
|
||||
|---|---|---|---:|
|
||||
| English | `en` | full | 100% |
|
||||
| Simplified Chinese | `zh-hans` | partial (messages in English) | 85% |
|
||||
| Spanish | `es` | full | 100% |
|
||||
| Hindi | `hi` | none | 0% |
|
||||
| Arabic | `ar` | none (no right-to-left interface) | 0% |
|
||||
| French | `fr` | full | 100% |
|
||||
| Portuguese (Brazil) | `pt-br` | partial | 68% |
|
||||
| Indonesian | `id` | none | 0% |
|
||||
| Japanese | `ja` | full | 100% |
|
||||
| German | `de` | full | 98% |
|
||||
| Korean | `ko` | none | 0% |
|
||||
| Vietnamese | `vi` | none | 0% |
|
||||
|
||||
_Method (2026-10-02; M3 rows re-derived 2026-10-04, file, export, print, links and UI-chrome rows after M4 on 2026-10-05):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of
|
||||
real users' work) each represents. Each area is scored by depth of behaviour, not by presence of a menu item: an area
|
||||
is 100% only when every feature in it behaves like Illustrator. Feature parity = Σ weight × score / Σ weight. Remaining
|
||||
time is counted per area from what is missing, calibrated on measured throughput: in the last session one agent landed
|
||||
about 20 medium features (Text Wrap, Graphs, Effect → Pathfinder, export baking…) in ~16 h wall clock including builds
|
||||
and CI on a heavily loaded machine — about 0.8 h per medium feature; large subsystems (3D, raster filters, vertical type)
|
||||
are counted bottom-up. The M3 rows (colour, strokes, appearance) come from a detailed 92-task M3 plan whose review
|
||||
found more missing depth than the first estimate; all 92 tasks landed on 2026-10-04 and 2026-10-05 with 6–8 agents in parallel; its packages, planned at 5–11 h each, took
|
||||
about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) are likely high.
|
||||
Also shipped: Traditional Chinese (partial, 80%), Italian, Russian and Ukrainian (full), Czech (menus only).
|
||||
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Area | Weight | Done | Missing (main items) | One agent (h) |
|
||||
|---|---:|---:|---|---:|
|
||||
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
|
||||
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
|
||||
| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 32% | ~39 Photoshop-style filters (Artistic, Brush Strokes, Distort, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 20–33 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4 |
|
||||
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 |
|
||||
| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
|
||||
| **Feature parity** | **103** | **~75%** | | **~210–330** |
|
||||
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
|
||||
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
|
||||
| **Full parity** | | **~56%** | | **~320–500** |
|
||||
## Upcoming
|
||||
|
||||
With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4
|
||||
(integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity,
|
||||
**~80–145 h** to full parity.
|
||||
Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md).
|
||||
|
||||
_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except Touch Type; 51 panel modules; Illustrator-style live
|
||||
effects ~44/54, Photoshop-style raster effects ~4/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
1. **Interaction-fidelity pass** (G1), tool by tool from [ui-parity.md](docs/ui-parity.md): 60–90 h.
|
||||
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
|
||||
3. **Launch and stability on real machines** (G10): 20–35 h.
|
||||
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
|
||||
5. **Raster effects** (G2) alongside: 29 filters and the Effect Gallery, 24–40 h.
|
||||
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
|
||||
|
||||
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
|
||||
lossless SVG/PDF export of live effects with SVG filters, a documented JSON format, the same app on the web, and every
|
||||
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
|
||||
## Progress log
|
||||
|
||||
## Out of scope (by design or by law)
|
||||
- **Native `.ai` private data, beyond its structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
|
||||
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2) |
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
| 2026-10-07 | v0.4.0; issue fixes; Layers panel rework |
|
||||
| 2026-10-06 | M8 transform and distort fidelity pass (M8.1–M8.20) |
|
||||
| 2026-10-05 | M4 files done (M4.14–M4.98); M3 paint and appearance done (92 tasks over two days) |
|
||||
| 2026-10-02 | First weighted parity estimate (22 areas); look and feel measured against public screenshots |
|
||||
| 2026-10-01 | Renamed from DrawCraft to VectorCraft |
|
||||
| 2026-09-30 | Repository history begins |
|
||||
|
||||
## Where we aim to be better than Illustrator
|
||||
- **Speed:** off-thread multithreaded rendering, instant startup, a responsive UI on huge files.
|
||||
- **Robustness:** exact curve booleans (no "cannot perform operation"), property-tested undo/redo and file round trips.
|
||||
- **Openness:** a documented native format, first-class SVG, and the same app in the browser.
|
||||
- **Automation:** every command, gesture, dialog and widget is drivable by agents (JSON channel + MCP), with headless batch mode.
|
||||
The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/roadmap.md#progress-log-before-the-progress-docs-standard).
|
||||
|
||||
## How to update this file
|
||||
After each milestone task lands, update the status column and remaining estimates, and move items into "Shipped so far". Keep estimates honest: re-derive them from what remains, never from wishful velocity.
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Method aligned with the standard, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away |
|
||||
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%) |
|
||||
| 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md |
|
||||
| 2026-10-10 | minor | Applied the core-workflow gate (docs/roadmap.md › Alpha gate): passes, stage stays alpha |
|
||||
| 2026-10-10 | major | Re-measured against Illustrator 2026 (30.x) from code counts, 71 open issues and public docs; stage set to alpha; moved the parity estimate, honest assessment and "Shipped so far" to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, milestones to `docs/roadmap.md`, the `.ai` scope to `docs/file-format-parity.md` |
|
||||
| 2026-10-05 | major | Honest assessment by dimension and the prioritized gap list |
|
||||
| 2026-10-02 | major | First 22-area weighted parity estimate |
|
||||
@@ -51,10 +51,13 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
|
||||
# with default features off). Pinned exactly: review the diff before bumping.
|
||||
muda = { version = "=0.21.1", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSMenu", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext", "NSView", "NSWindow"] }
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
# The window's AppKit view, to centre its buttons on the app bar (src/mac_window.rs). MIT OR
|
||||
# Apache-2.0 OR Zlib, already in the build through eframe and winit.
|
||||
raw-window-handle = "0.6"
|
||||
# CoreText's font manager (src/mac_fonts.rs): fonts that apps and font managers register from their
|
||||
# own folders (#579). Zlib OR Apache-2.0 OR MIT; objc2-core-foundation already comes with
|
||||
# objc2-foundation.
|
||||
|
||||
@@ -26,9 +26,16 @@
|
||||
//! the graphics fail while the window is starting up, [`finish`] starts the app again without
|
||||
//! that adapter, as the next one in the order would be tried. Each restart leaves out one more
|
||||
//! adapter, so it ends when none is left.
|
||||
//!
|
||||
//! An adapter can also hang instead of failing: on a hybrid laptop with High Performance chosen,
|
||||
//! the discrete GPU never put the window's first frame on the screen, with no error and no panic,
|
||||
//! and the app stayed in the background without a window (#964). [`watch_first_frame`] gives the
|
||||
//! first frame [`FIRST_FRAME_TIMEOUT`] once the app is created and, when it doesn't come, starts
|
||||
//! the app again without that adapter in the same way.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
|
||||
use eframe::wgpu::{self, Backend, Backends, DeviceType, PowerPreference};
|
||||
@@ -50,6 +57,11 @@ const HELP: &str =
|
||||
/// window is something else (such as the compositor restarting).
|
||||
pub const STARTUP_FRAMES: u64 = 10;
|
||||
|
||||
/// How long the window's first frame may take to reach the screen, from the app's creation,
|
||||
/// before its adapter counts as hanging ([`watch_first_frame`]). The first frame draws the whole
|
||||
/// interface and compiles its shaders, a second at most on a slow machine.
|
||||
pub const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// The preference used when Preferences say Automatic. Windows and macOS show frames from any GPU
|
||||
/// (the integrated one avoids the flicker of #306). Elsewhere, the system's own order: Mesa's
|
||||
/// device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid
|
||||
@@ -311,6 +323,8 @@ pub struct Startup {
|
||||
adapter: OnceLock<String>,
|
||||
/// The UI's context, which counts the frames run.
|
||||
ui: OnceLock<egui::Context>,
|
||||
/// When the app was created, the files named at the start opened: the first frame is due.
|
||||
ready: OnceLock<Instant>,
|
||||
}
|
||||
|
||||
impl Startup {
|
||||
@@ -319,6 +333,71 @@ impl Startup {
|
||||
// Set once; the window is created once.
|
||||
let _ = self.ui.set(ctx.clone());
|
||||
}
|
||||
|
||||
/// The app is ready to draw its first frame.
|
||||
pub fn ready(&self) {
|
||||
// Set once, like `created`.
|
||||
let _ = self.ready.set(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`watch_first_frame`] makes of the window's start.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum FirstFrame {
|
||||
/// Not due yet.
|
||||
Wait,
|
||||
/// On the screen (the second frame began, so the first was presented), or the window was
|
||||
/// minimized at the start: nothing to watch.
|
||||
Shown,
|
||||
/// Overdue.
|
||||
Hung,
|
||||
}
|
||||
|
||||
/// The window's start after `frames` frames run, `since` its first frame was due.
|
||||
fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame {
|
||||
if frames >= 2 || minimized {
|
||||
FirstFrame::Shown
|
||||
} else if since >= FIRST_FRAME_TIMEOUT {
|
||||
FirstFrame::Hung
|
||||
} else {
|
||||
FirstFrame::Wait
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch the window's first frame from another thread: when it hasn't reached the screen
|
||||
/// [`FIRST_FRAME_TIMEOUT`] after the app was created ([`Startup::ready`]), start the app again
|
||||
/// without the adapter, as [`finish`] does for one that failed, and end this process, whose UI
|
||||
/// thread is stuck in the graphics driver.
|
||||
pub fn watch_first_frame(startup: Arc<Startup>) {
|
||||
let watch = move || loop {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
let Some(due) = startup.ready.get() else { continue };
|
||||
let ui = startup.ui.get();
|
||||
let frames = ui.map_or(0, egui::Context::cumulative_frame_nr);
|
||||
let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false);
|
||||
match first_frame(frames, due.elapsed(), minimized) {
|
||||
FirstFrame::Wait => continue,
|
||||
FirstFrame::Shown => return,
|
||||
FirstFrame::Hung => {}
|
||||
}
|
||||
let mut skip = skipped();
|
||||
let Some(failed) = restart_without(true, frames, startup.adapter.get().map(String::as_str), &skip) else { return };
|
||||
log::error!(
|
||||
"the window's first frame didn't reach the screen in {}s on graphics adapter {failed}: starting again without it",
|
||||
FIRST_FRAME_TIMEOUT.as_secs()
|
||||
);
|
||||
skip.push(failed.to_string());
|
||||
match restart(&skip.join(",")) {
|
||||
Ok(()) => std::process::exit(0),
|
||||
Err(e) => {
|
||||
log::error!("starting again failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Err(e) = std::thread::Builder::new().name("first frame".into()).spawn(watch) {
|
||||
log::warn!("no watch on the window's first frame: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPUs that drive a
|
||||
@@ -729,6 +808,24 @@ mod tests {
|
||||
assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() {
|
||||
let s = Duration::from_secs;
|
||||
// The second frame began: the first was presented.
|
||||
assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown);
|
||||
// Still drawing (or presenting) the first frame: waited for, then given up on (#964).
|
||||
for frames in [0, 1] {
|
||||
assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait);
|
||||
assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung);
|
||||
}
|
||||
// A window minimized at the start may not draw again until restored.
|
||||
assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown);
|
||||
// A hung adapter is left out once, like one that failed.
|
||||
let skip = vec!["Dx12:10de:28a0".to_string()];
|
||||
assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None);
|
||||
assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restart_tries_the_next_adapter() {
|
||||
let c = issue_502();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! macOS: the window buttons (close, minimize, zoom) centred on the app bar (#968).
|
||||
//!
|
||||
//! AppKit puts the buttons in a standard title bar, shorter than the app bar they sit over, so they
|
||||
//! rode high on it. [`center_buttons`] moves them to the middle of the bar, inset from its left as
|
||||
//! far as from its top, and makes the title bar as tall as the app bar so they stay clickable.
|
||||
//! AppKit lays its title bar out again when the window resizes or leaves full screen, so this runs
|
||||
//! every frame and moves the buttons only when they are out of place.
|
||||
//!
|
||||
//! Reaching the title bar's views from the window's content view has no safe API, hence the scoped
|
||||
//! `unsafe_code` allowance.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use objc2::runtime::AnyObject;
|
||||
use objc2_app_kit::{NSView, NSWindowButton, NSWindowStyleMask};
|
||||
use objc2_foundation::NSPoint;
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
|
||||
/// The room the app bar keeps at its left for the buttons in AppKit's own place, before they are
|
||||
/// centred (and in full screen, where AppKit shows them elsewhere): their width and a gap.
|
||||
pub const DEFAULT_INSET: f32 = 78.0;
|
||||
|
||||
/// Differences below this (in points) leave a frame as it is, so a frame in place isn't set again.
|
||||
const SLACK: f64 = 0.25;
|
||||
|
||||
/// Centre the window's buttons on an app bar `bar` egui points tall, at the window's `zoom` factor
|
||||
/// (native points per egui point). Returns the room the app bar keeps clear at its left for them,
|
||||
/// in egui points, or `None` when they aren't over the bar (full screen) or can't be found.
|
||||
pub fn center_buttons(frame: &eframe::Frame, zoom: f32, bar: f32) -> Option<f32> {
|
||||
let RawWindowHandle::AppKit(handle) = frame.window_handle().ok()?.as_raw() else {
|
||||
return None;
|
||||
};
|
||||
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
|
||||
// window is open, and eframe hands out the handle only while it is.
|
||||
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
|
||||
let window = view.window()?;
|
||||
if window.styleMask().contains(NSWindowStyleMask::FullScreen) {
|
||||
return None;
|
||||
}
|
||||
let close = window.standardWindowButton(NSWindowButton::CloseButton)?;
|
||||
let minimize = window.standardWindowButton(NSWindowButton::MiniaturizeButton)?;
|
||||
let zoom_button = window.standardWindowButton(NSWindowButton::ZoomButton)?;
|
||||
// The buttons sit in the title bar view, inside the title bar container that sets its height.
|
||||
// SAFETY: both views belong to the window, alive while it is; used right away.
|
||||
let container = unsafe { close.superview().and_then(|titlebar| titlebar.superview()) }?;
|
||||
// Another layout (a later macOS) is left as AppKit made it.
|
||||
if AnyObject::class(&container).name() != c"NSTitlebarContainerView" {
|
||||
return None;
|
||||
}
|
||||
let size = close.frame().size;
|
||||
let gap = minimize.frame().origin.x - (close.frame().origin.x + size.width);
|
||||
let height = f64::from(bar * zoom);
|
||||
let margin = (height - size.height) / 2.0;
|
||||
if !(margin.is_finite() && margin > 0.0 && gap.is_finite() && gap >= 0.0 && zoom > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let mut bounds = container.frame();
|
||||
let top = window.frame().size.height - height;
|
||||
if (bounds.size.height - height).abs() > SLACK || (bounds.origin.y - top).abs() > SLACK {
|
||||
bounds.size.height = height;
|
||||
bounds.origin.y = top;
|
||||
container.setFrame(bounds);
|
||||
}
|
||||
let mut x = margin;
|
||||
for button in [close, minimize, zoom_button] {
|
||||
let at = NSPoint::new(x, (height - button.frame().size.height) / 2.0);
|
||||
let now = button.frame().origin;
|
||||
if (now.x - at.x).abs() > SLACK || (now.y - at.y).abs() > SLACK {
|
||||
button.setFrameOrigin(at);
|
||||
}
|
||||
x += size.width + gap;
|
||||
}
|
||||
// The last button's right edge (`x` steps one gap past it) and the margin again after it.
|
||||
Some(((x - gap + margin) / f64::from(zoom)) as f32)
|
||||
}
|
||||
@@ -31,6 +31,8 @@ mod mac_fonts;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
mod prefs_dir;
|
||||
mod printing;
|
||||
@@ -69,7 +71,14 @@ impl eframe::App for App {
|
||||
}
|
||||
file_access::unconfined(|| self.app.raw_input_hook(raw));
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT;
|
||||
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
|
||||
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
|
||||
}
|
||||
file_access::unconfined(|| self.app.ui(ui));
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.app.take_ime_discard() {
|
||||
@@ -391,6 +400,32 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
||||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
|
||||
/// User Interface › System Title Bar asks for the system's. Read from the saved preferences
|
||||
/// before the window opens; a missing or unreadable value keeps the default.
|
||||
fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
|
||||
CUSTOM_TITLEBAR && !saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
.with_min_inner_size(window::MIN_SIZE)
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!custom_titlebar)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_icon(app_icon())
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
#[cfg(feature = "windows7")]
|
||||
renderer: eframe::Renderer::Glow,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// First, so every start-up warning is recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
@@ -469,7 +504,9 @@ fn main() -> std::process::ExitCode {
|
||||
#[cfg(feature = "wgpu")]
|
||||
let displays = gpu::preferred_displays(gpu_pref, power_env);
|
||||
#[cfg(feature = "wgpu")]
|
||||
if gpu::automatic(gpu_pref, power_env) {
|
||||
let chosen_gpu = !gpu::automatic(gpu_pref, power_env);
|
||||
#[cfg(feature = "wgpu")]
|
||||
if !chosen_gpu {
|
||||
let listed: Vec<String> = displays.iter().map(ToString::to_string).collect();
|
||||
log::info!("display GPUs (PCI vendor:device): {}", if listed.is_empty() { "unknown".to_string() } else { listed.join(", ") });
|
||||
} else {
|
||||
@@ -477,22 +514,8 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
#[cfg(feature = "wgpu")]
|
||||
let startup = std::sync::Arc::new(gpu::Startup::default());
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
.with_min_inner_size(window::MIN_SIZE)
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_icon(app_icon())
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
#[cfg(feature = "windows7")]
|
||||
renderer: eframe::Renderer::Glow,
|
||||
..Default::default()
|
||||
};
|
||||
let custom_titlebar = custom_titlebar(saved.as_ref());
|
||||
let options = native_options(custom_titlebar);
|
||||
#[cfg(feature = "wgpu")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
@@ -513,6 +536,8 @@ fn main() -> std::process::ExitCode {
|
||||
};
|
||||
#[cfg(feature = "wgpu")]
|
||||
gpu::watch_panics();
|
||||
#[cfg(feature = "wgpu")]
|
||||
gpu::watch_first_frame(startup.clone());
|
||||
// Files opened from Finder and the Dock arrive as events, not arguments.
|
||||
#[cfg(target_os = "macos")]
|
||||
open_documents::install();
|
||||
@@ -556,7 +581,15 @@ fn main() -> std::process::ExitCode {
|
||||
log::info!("rendering with {summary} (power preference {power:?})");
|
||||
created.created(&cc.egui_ctx);
|
||||
if !gpu::skipped().is_empty() {
|
||||
app.status(format!("The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}"));
|
||||
// A processor chosen in Settings can be the one that can't show the window.
|
||||
let hint = if chosen_gpu {
|
||||
" (Settings › Performance › Graphics Processor › Automatic uses the one that drives your display)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
app.status(format!(
|
||||
"The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}{hint}"
|
||||
));
|
||||
}
|
||||
app.graphics_adapter = Some(summary);
|
||||
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
|
||||
@@ -566,8 +599,7 @@ fn main() -> std::process::ExitCode {
|
||||
{
|
||||
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx).with_automation_roots(roots.clone());
|
||||
@@ -589,6 +621,9 @@ fn main() -> std::process::ExitCode {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = in_window_menus;
|
||||
file_access::unconfined(|| open_files(&mut app, files));
|
||||
// The first frame is due from now (`gpu::watch_first_frame`).
|
||||
#[cfg(feature = "wgpu")]
|
||||
created.ready();
|
||||
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 }))
|
||||
}),
|
||||
)
|
||||
@@ -610,6 +645,25 @@ fn main() -> std::process::ExitCode {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ui_state_with_prefs(engine_prefs: serde_json::Value) -> vectorcraft_ui_egui::UiState {
|
||||
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_title_bar_preference_keeps_the_window_decorations() {
|
||||
// Preferences › User Interface › System Title Bar gives the window back its system
|
||||
// decorations on Windows and Linux; macOS always has them.
|
||||
assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences: the default");
|
||||
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
|
||||
assert_eq!(super::custom_titlebar(Some(&off)), super::CUSTOM_TITLEBAR);
|
||||
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
|
||||
assert!(!super::custom_titlebar(Some(&on)));
|
||||
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
|
||||
if super::CUSTOM_TITLEBAR {
|
||||
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// The file extensions the macOS bundle declares: its document types and its own exported type.
|
||||
fn plist_extensions(plist: &str) -> Vec<&str> {
|
||||
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
|
||||
|
||||
@@ -69,6 +69,70 @@ impl GraphKind {
|
||||
GraphKind::Radar => "Radar",
|
||||
}
|
||||
}
|
||||
/// Whether a series of this type can be drawn in a graph of type `graph` (Combine different graph types): the
|
||||
/// column, stacked column, line and area types share a vertical value axis, the bar types a horizontal one.
|
||||
/// Scatter, pie and radar lay their data out on their own and take no other types.
|
||||
pub fn combines_with(self, graph: GraphKind) -> bool {
|
||||
use GraphKind::*;
|
||||
let vertical = |k| matches!(k, Column | StackedColumn | Line | Area);
|
||||
let horizontal = |k| matches!(k, Bar | StackedBar);
|
||||
self == graph || (vertical(self) && vertical(graph)) || (horizontal(self) && horizontal(graph))
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph Type › Value Axis: which side of the plot the value axis is drawn on.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ValueAxisSide {
|
||||
#[default]
|
||||
Left,
|
||||
Right,
|
||||
Both,
|
||||
}
|
||||
|
||||
impl ValueAxisSide {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
ValueAxisSide::Left => "left",
|
||||
ValueAxisSide::Right => "right",
|
||||
ValueAxisSide::Both => "both",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[ValueAxisSide::Left, ValueAxisSide::Right, ValueAxisSide::Both].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
|
||||
}
|
||||
fn is_left(&self) -> bool {
|
||||
*self == ValueAxisSide::Left
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph Type › Tick Marks › Length: no tick marks, short ones outside the axis, or lines across the whole plot.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TickLength {
|
||||
None,
|
||||
#[default]
|
||||
Short,
|
||||
Full,
|
||||
}
|
||||
|
||||
impl TickLength {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TickLength::None => "none",
|
||||
TickLength::Short => "short",
|
||||
TickLength::Full => "full",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[TickLength::None, TickLength::Short, TickLength::Full].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
|
||||
}
|
||||
fn is_short(&self) -> bool {
|
||||
*self == TickLength::Short
|
||||
}
|
||||
fn is_none(&self) -> bool {
|
||||
*self == TickLength::None
|
||||
}
|
||||
}
|
||||
|
||||
/// The fill and stroke captured from one series' generated marks. An absent paint keeps whatever
|
||||
@@ -95,6 +159,10 @@ pub struct GraphSpec {
|
||||
/// entries keep the generator's own fill and stroke.
|
||||
pub series_paints: Vec<SeriesPaint>,
|
||||
pub kind: GraphKind,
|
||||
/// Graph types chosen for single series (Combine different graph types), by series index; `None` and missing
|
||||
/// entries draw as [`Self::kind`].
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub series_kinds: Vec<Option<GraphKind>>,
|
||||
/// The plot rectangle (the area the graph tool dragged), document points.
|
||||
pub rect: Rect,
|
||||
pub series: Vec<String>,
|
||||
@@ -114,6 +182,50 @@ pub struct GraphSpec {
|
||||
pub ticks: usize,
|
||||
pub axis_min: Option<f64>,
|
||||
pub axis_max: Option<f64>,
|
||||
/// Graph Type › Value Axis side. Column, stacked column, line, area and scatter graphs (the bar graphs' value axis
|
||||
/// runs along the bottom and stays there).
|
||||
#[serde(skip_serializing_if = "ValueAxisSide::is_left")]
|
||||
pub value_axis: ValueAxisSide,
|
||||
/// Graph Type › Separate Scales: with the value axis on both sides, the series on the right axis
|
||||
/// ([`Self::right_series`]) get a scale of their own there.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub separate_scales: bool,
|
||||
/// Indexes of the series assigned to the right value axis.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub right_series: Vec<usize>,
|
||||
/// The right value axis' tick values, used with separate scales: tick count (0 = automatic) and min/max override.
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub right_ticks: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub right_axis_min: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub right_axis_max: Option<f64>,
|
||||
/// Graph Type › Tick Marks for the value axis (the left one, or the bottom one of bar graphs) and the right one:
|
||||
/// length, and tick marks per division (0 and 1 = one, at the labels).
|
||||
#[serde(skip_serializing_if = "TickLength::is_short")]
|
||||
pub tick_length: TickLength,
|
||||
#[serde(skip_serializing_if = "at_most_one")]
|
||||
pub tick_marks: usize,
|
||||
#[serde(skip_serializing_if = "TickLength::is_short")]
|
||||
pub right_tick_length: TickLength,
|
||||
#[serde(skip_serializing_if = "at_most_one")]
|
||||
pub right_tick_marks: usize,
|
||||
/// Tick Marks for the category axis (none by default), and Draw Tick Marks Between Labels.
|
||||
#[serde(skip_serializing_if = "TickLength::is_none")]
|
||||
pub category_tick_length: TickLength,
|
||||
#[serde(skip_serializing_if = "at_most_one")]
|
||||
pub category_tick_marks: usize,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub ticks_between_labels: bool,
|
||||
/// Graph Type › Add Labels: text before and after the value axes' numbers.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub prefix: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub suffix: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub right_prefix: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub right_suffix: String,
|
||||
/// Line, scatter and radar graphs: mark data points, connect them.
|
||||
pub mark_points: bool,
|
||||
pub connect_points: bool,
|
||||
@@ -127,12 +239,25 @@ pub struct GraphSpec {
|
||||
pub placed: Option<Rect>,
|
||||
}
|
||||
|
||||
fn at_most_one(n: &usize) -> bool {
|
||||
*n <= 1
|
||||
}
|
||||
|
||||
fn is_zero(n: &usize) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
|
||||
/// Edge-to-Edge Lines for a graph saved before the option existed: on, as it was drawn then.
|
||||
fn edge_to_edge_before_the_option() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl GraphSpec {
|
||||
/// The type series `s` is drawn as: its own type when it combines with the graph's, else the graph's.
|
||||
pub fn series_kind(&self, s: usize) -> GraphKind {
|
||||
self.series_kinds.get(s).copied().flatten().filter(|k| k.combines_with(self.kind)).unwrap_or(self.kind)
|
||||
}
|
||||
|
||||
/// The data with blank cells as `None`, every row as long as the longest (at most [`MAX_GRAPH_CATEGORIES`] rows
|
||||
/// of [`MAX_GRAPH_SERIES`] cells).
|
||||
pub fn cells(&self) -> Vec<Vec<Option<f64>>> {
|
||||
@@ -159,6 +284,7 @@ impl Default for GraphSpec {
|
||||
Self {
|
||||
series_paints: vec![],
|
||||
kind: GraphKind::Column,
|
||||
series_kinds: vec![],
|
||||
rect: Rect::new(0.0, 0.0, 200.0, 150.0),
|
||||
series: vec![],
|
||||
categories: vec![],
|
||||
@@ -170,6 +296,23 @@ impl Default for GraphSpec {
|
||||
ticks: 0,
|
||||
axis_min: None,
|
||||
axis_max: None,
|
||||
value_axis: ValueAxisSide::Left,
|
||||
separate_scales: false,
|
||||
right_series: vec![],
|
||||
right_ticks: 0,
|
||||
right_axis_min: None,
|
||||
right_axis_max: None,
|
||||
tick_length: TickLength::Short,
|
||||
tick_marks: 1,
|
||||
right_tick_length: TickLength::Short,
|
||||
right_tick_marks: 1,
|
||||
category_tick_length: TickLength::None,
|
||||
category_tick_marks: 1,
|
||||
ticks_between_labels: false,
|
||||
prefix: String::new(),
|
||||
suffix: String::new(),
|
||||
right_prefix: String::new(),
|
||||
right_suffix: String::new(),
|
||||
mark_points: true,
|
||||
connect_points: true,
|
||||
edge_to_edge: false,
|
||||
|
||||
@@ -41,6 +41,7 @@ pub mod style_libs;
|
||||
pub mod swatches;
|
||||
pub mod text;
|
||||
pub mod trace;
|
||||
pub mod variables;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -65,7 +66,7 @@ pub use appearance::{
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
pub use graph::{GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint};
|
||||
pub use graph::{GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint, TickLength, ValueAxisSide};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
@@ -91,6 +92,7 @@ pub use text::{
|
||||
TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use trace::TraceView;
|
||||
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
|
||||
pub use vectorcraft_color as color;
|
||||
pub use vectorcraft_geom as geom;
|
||||
|
||||
@@ -561,6 +563,9 @@ pub struct Document {
|
||||
/// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub saved_selections: Vec<SavedSelection>,
|
||||
/// Variables (data merge): named values bound to objects, applied per dataset.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub variables: Variables,
|
||||
#[serde(default)]
|
||||
pub grid: GridPrefs,
|
||||
#[serde(default = "ppi72")]
|
||||
@@ -693,6 +698,7 @@ impl Document {
|
||||
guides: vec![],
|
||||
views: vec![],
|
||||
saved_selections: vec![],
|
||||
variables: Variables::default(),
|
||||
grid: GridPrefs::default(),
|
||||
raster_effects_ppi: 72.0,
|
||||
images: BTreeMap::new(),
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Variables (data merge): named values bound to objects, with datasets that apply
|
||||
//! them all at once. One template document produces many variants (badges, price
|
||||
//! lists, localized copies) by selecting a dataset instead of editing each object.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Document, NodeId, NodeKind};
|
||||
|
||||
/// What a variable drives.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum VariableKind {
|
||||
/// A type object's characters.
|
||||
#[default]
|
||||
Text,
|
||||
/// Whether bound objects show.
|
||||
Visibility,
|
||||
}
|
||||
|
||||
impl VariableKind {
|
||||
/// Parse the `kind` of `variable.define`: `text` or `visibility` (`visible` is accepted
|
||||
/// as the same thing, as the panel and the docs call it both).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"text" => Some(Self::Text),
|
||||
"visibility" | "visible" => Some(Self::Visibility),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind's own name, as `variable.define` takes it and `variable.list` reports it.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Visibility => "visibility",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One named variable.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Variable {
|
||||
pub name: String,
|
||||
pub kind: VariableKind,
|
||||
}
|
||||
|
||||
/// One value a dataset gives a variable.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DataValue {
|
||||
Text(String),
|
||||
Visible(bool),
|
||||
}
|
||||
|
||||
impl DataValue {
|
||||
/// Whether this value fits the variable's kind (a dataset may carry values for
|
||||
/// variables of either kind; each binding takes what fits it).
|
||||
pub fn fits(&self, kind: VariableKind) -> bool {
|
||||
matches!((self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility))
|
||||
}
|
||||
}
|
||||
|
||||
/// One named row of values, applied with `dataset.select`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DataSet {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub values: BTreeMap<String, DataValue>,
|
||||
}
|
||||
|
||||
impl DataSet {
|
||||
/// Whether the art, as [`Document::captured_values`] read it, still holds what this row
|
||||
/// says. A value the art can't report (its object is locked or gone) isn't drift: the row
|
||||
/// keeps it, and applying it again would skip it too.
|
||||
pub fn matches(&self, current: &BTreeMap<String, DataValue>) -> bool {
|
||||
self.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v))
|
||||
}
|
||||
}
|
||||
|
||||
/// The variable state of a document: definitions, datasets, bindings
|
||||
/// (object id → the variables bound to it) and which dataset is active.
|
||||
///
|
||||
/// An object may be driven by several variables — the reference app binds the selected object
|
||||
/// to the selected variable, one click at a time, and never says a second binding replaces the
|
||||
/// first — so a binding is a set. `dataset.select` applies them in the order the variables are
|
||||
/// defined, which is what makes two text variables on one object deterministic: the last one
|
||||
/// wins, and `applied` counts both.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Variables {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub variables: Vec<Variable>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub datasets: Vec<DataSet>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub bindings: BTreeMap<NodeId, BTreeSet<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active_dataset: Option<String>,
|
||||
}
|
||||
|
||||
impl Variables {
|
||||
/// The variable `name`, if the document defines it.
|
||||
pub fn variable(&self, name: &str) -> Option<&Variable> {
|
||||
self.variables.iter().find(|v| v.name == name)
|
||||
}
|
||||
|
||||
/// The dataset `name`, if the document has it.
|
||||
pub fn dataset(&self, name: &str) -> Option<&DataSet> {
|
||||
self.datasets.iter().find(|d| d.name == name)
|
||||
}
|
||||
|
||||
/// Bind `id` to the variable `name`. True when it was not bound to it already.
|
||||
pub fn bind(&mut self, id: NodeId, name: &str) -> bool {
|
||||
self.bindings.entry(id).or_default().insert(name.to_string())
|
||||
}
|
||||
|
||||
/// Drop `id`'s binding to `name` (and the binding itself when that was its last variable).
|
||||
/// True when there was one.
|
||||
pub fn unbind(&mut self, id: NodeId, name: &str) -> bool {
|
||||
let Some(bound) = self.bindings.get_mut(&id) else { return false };
|
||||
let dropped = bound.remove(name);
|
||||
if bound.is_empty() {
|
||||
self.bindings.remove(&id);
|
||||
}
|
||||
dropped
|
||||
}
|
||||
|
||||
/// The objects bound to the variable `name`, in document order.
|
||||
pub fn objects_of(&self, name: &str) -> Vec<NodeId> {
|
||||
let mut ids: Vec<NodeId> = self.bindings.iter().filter(|(_, v)| v.contains(name)).map(|(id, _)| *id).collect();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Drop the variables `names`, the dataset values that name them and the bindings to
|
||||
/// them. Datasets named here are left alone: their names are their own namespace (the
|
||||
/// active one is dropped by whoever deletes the dataset).
|
||||
pub fn prune(&mut self, names: &[String]) -> usize {
|
||||
// A set, so a long list of names costs a lookup per value rather than a scan.
|
||||
let names: BTreeSet<&str> = names.iter().map(String::as_str).collect();
|
||||
let before = self.variables.len();
|
||||
self.variables.retain(|v| !names.contains(v.name.as_str()));
|
||||
for dataset in &mut self.datasets {
|
||||
dataset.values.retain(|k, _| !names.contains(k.as_str()));
|
||||
}
|
||||
for bound in self.bindings.values_mut() {
|
||||
bound.retain(|v| !names.contains(v.as_str()));
|
||||
}
|
||||
self.bindings.retain(|_, v| !v.is_empty());
|
||||
before - self.variables.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Forget the bindings of objects that are no longer in the document (after every edit),
|
||||
/// like [`Document::prune_assets`] does for Asset Export's. A binding outliving its object
|
||||
/// would count as a skip in every dataset application and grow the saved file forever.
|
||||
pub fn prune_variable_bindings(&mut self) {
|
||||
if self.variables.bindings.keys().all(|id| self.node(*id).is_some()) {
|
||||
return;
|
||||
}
|
||||
let mut bindings = std::mem::take(&mut self.variables.bindings);
|
||||
bindings.retain(|id, _| self.node(*id).is_some());
|
||||
self.variables.bindings = bindings;
|
||||
}
|
||||
|
||||
/// Whether a variable may read and rewrite the object `id`: it is in the document, neither
|
||||
/// it nor anything above it is locked, and nothing above it is hidden. Its own visibility
|
||||
/// doesn't count, as that is what a visibility variable drives: a row that hid it must be
|
||||
/// able to show it again, and a capture must record it hidden.
|
||||
pub fn variable_target(&self, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
|
||||
}
|
||||
|
||||
/// What the object `id` holds for the variable of `kind`: its characters, or whether it
|
||||
/// shows. `None` when the object is gone, is not of that kind, or is locked or on a hidden
|
||||
/// or locked layer (the document's value is whatever it was left at).
|
||||
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
|
||||
if !self.variable_target(id) {
|
||||
return None;
|
||||
}
|
||||
let node = self.node(id)?;
|
||||
match (kind, &node.kind) {
|
||||
(VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())),
|
||||
(VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)),
|
||||
(VariableKind::Text, _) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The values a dataset captured right now: what each bound object holds, in the order the
|
||||
/// variables are defined. `dataset.capture` writes these into a new row and
|
||||
/// `dataset.update` into the active one; the Variables panel compares the active row with
|
||||
/// them to tell whether the art still matches what it says.
|
||||
pub fn captured_values(&self) -> BTreeMap<String, DataValue> {
|
||||
let mut out = BTreeMap::new();
|
||||
for v in &self.variables.variables {
|
||||
// An object driven by several variables of the same kind reads from the first one
|
||||
// bound, so a capture is one value per variable and applies back the same way.
|
||||
let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value(id, v.kind));
|
||||
if let Some(value) = value {
|
||||
out.insert(v.name.clone(), value);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-three-d = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
|
||||
@@ -39,7 +39,8 @@ fn clear_item_effects(item: &mut AppearanceItem) {
|
||||
|
||||
/// Does anything in `n`'s subtree need baking?
|
||||
pub fn needs_bake(n: &Node) -> bool {
|
||||
has_adjustment(n)
|
||||
crate::has_revolve(n)
|
||||
|| has_adjustment(n)
|
||||
|| has_crop_marks(n)
|
||||
|| has_pathfinder(n)
|
||||
|| has_own_paint(n)
|
||||
@@ -92,6 +93,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
if let Some(m) = crop_marks_art(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
if let Some(m) = crate::revolve_art(n) {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
|
||||
let symbol = match &n.kind {
|
||||
@@ -226,6 +231,10 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &
|
||||
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
|
||||
/// effects are dropped. `None` for other kinds of objects.
|
||||
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve::expand_revolve_art(n) {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
let (g, rule, ctx, mut m) = leaf_base(n)?;
|
||||
m.appearance.effects.retain(|e| e.visible);
|
||||
let mut pieces = vec![];
|
||||
@@ -289,6 +298,9 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
||||
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
|
||||
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
|
||||
pub fn bake_appearance(n: &Node) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve_art(n) {
|
||||
return Some(m);
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ mod marks;
|
||||
pub mod pixel;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
mod revolve;
|
||||
pub mod stroke;
|
||||
mod stylize;
|
||||
mod util;
|
||||
@@ -68,6 +69,7 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
||||
/// Catalogue entry for one effect.
|
||||
@@ -122,12 +124,18 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"distort.tweak" => Lengths { always: &[], absolute: &["h", "v"] },
|
||||
"distort.transform" => always(&["moveH", "moveV"]),
|
||||
"path.offsetPath" => always(&["offset"]),
|
||||
"threeD.revolve" => always(&["offset"]),
|
||||
"path.outlineStroke" => always(&["width"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
|
||||
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
|
||||
"stylize.dropShadow" => always(&["x", "y", "blur"]),
|
||||
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
|
||||
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
|
||||
"brushStrokes.accentedEdges" => always(&["edgeWidth"]),
|
||||
"brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]),
|
||||
"brushStrokes.spatter" => always(&["sprayRadius"]),
|
||||
"brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]),
|
||||
"brushStrokes.sumiE" => always(&["strokeWidth"]),
|
||||
"pixelate.colorHalftone" => always(&["maxRadius"]),
|
||||
"pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]),
|
||||
"texture.craquelure" => always(&["crackSpacing"]),
|
||||
@@ -146,6 +154,8 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
|
||||
const STYLIZE: &[&str] = &["Effect", "Stylize"];
|
||||
const WARP: &[&str] = &["Effect", "Warp"];
|
||||
const BLUR: &[&str] = &["Effect", "Blur"];
|
||||
const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"];
|
||||
const DISTORT: &[&str] = &["Effect", "Distort"];
|
||||
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
|
||||
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
|
||||
const TEXTURE: &[&str] = &["Effect", "Texture"];
|
||||
@@ -178,6 +188,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false, lengths: lengths_of(id) };
|
||||
let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true, lengths: lengths_of(id) };
|
||||
let mut v = vec![
|
||||
g(
|
||||
REVOLVE,
|
||||
"Revolve…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}),
|
||||
),
|
||||
g(
|
||||
"convertToShape.rectangle",
|
||||
"Rectangle…",
|
||||
@@ -269,7 +286,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"stylize.innerGlow",
|
||||
"Inner Glow…",
|
||||
STYLIZE,
|
||||
"{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"edge\"|\"center\"}",
|
||||
"{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"center\"|\"edge\" (\"edge\")}",
|
||||
json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffffff", "source": "edge"}),
|
||||
),
|
||||
r(
|
||||
@@ -295,6 +312,83 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)",
|
||||
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.accentedEdges",
|
||||
"Accented Edges…",
|
||||
BRUSH_STROKES,
|
||||
"{edgeWidth: pt 1..14 (2), edgeBrightness: 0..50 (38; high: white chalk, low: black ink), smoothness: 1..15 (5; softens the object first and leaves its faint edges out)} accentuates the object's edges",
|
||||
json!({"edgeWidth": 2.0, "edgeBrightness": 38.0, "smoothness": 5.0}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.angledStrokes",
|
||||
"Angled Strokes…",
|
||||
BRUSH_STROKES,
|
||||
"{directionBalance: 0..100 (50; 0: every stroke a left diagonal, 100: every stroke a right diagonal, between: light areas right, dark areas left), strokeLength: pt 3..50 (15), sharpness: 0..10 (3)} repaints the object with diagonal strokes, the light areas' going the opposite way to the dark areas'",
|
||||
json!({"directionBalance": 50.0, "strokeLength": 15.0, "sharpness": 3.0}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.crosshatch",
|
||||
"Crosshatch…",
|
||||
BRUSH_STROKES,
|
||||
"{strokeLength: pt 3..50 (9), sharpness: 0..20 (6), strength: 1..3 (1; passes of hatching)} keeps the object's detail under pencil hatching along both diagonals and roughens its edges",
|
||||
json!({"strokeLength": 9.0, "sharpness": 6.0, "strength": 1.0}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.darkStrokes",
|
||||
"Dark Strokes…",
|
||||
BRUSH_STROKES,
|
||||
"{balance: 0..10 (5; the higher, the more of the object counts as dark), blackIntensity: 0..10 (6), whiteIntensity: 0..10 (2)} paints the dark areas closer to black with short strokes and the light areas with long white strokes",
|
||||
json!({"balance": 5.0, "blackIntensity": 6.0, "whiteIntensity": 2.0}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.inkOutlines",
|
||||
"Ink Outlines…",
|
||||
BRUSH_STROKES,
|
||||
"{strokeLength: pt 1..50 (4), darkIntensity: 0..50 (20), lightIntensity: 0..50 (10)} redraws the object in pen and ink: fine outlines over its edges and ink strokes in its shadows",
|
||||
json!({"strokeLength": 4.0, "darkIntensity": 20.0, "lightIntensity": 10.0}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.spatter",
|
||||
"Spatter…",
|
||||
BRUSH_STROKES,
|
||||
"{sprayRadius: pt 0..25 (10; colours scattered up to 0.4 × this), smoothness: 1..15 (5; larger, smoother grains)} renders the object as if sprayed with a spatter airbrush",
|
||||
json!({"sprayRadius": 10.0, "smoothness": 5.0}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.sprayedStrokes",
|
||||
"Sprayed Strokes…",
|
||||
BRUSH_STROKES,
|
||||
"{strokeLength: pt 0..20 (12), sprayRadius: pt 0..25 (7; strokes scattered up to 0.3 × this), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} repaints the object with sprayed, angled strokes of its main colours",
|
||||
json!({"strokeLength": 12.0, "sprayRadius": 7.0, "strokeDirection": "rightDiagonal"}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.sumiE",
|
||||
"Sumi-e…",
|
||||
BRUSH_STROKES,
|
||||
"{strokeWidth: pt 3..15 (10), strokePressure: 0..15 (2; more ink), contrast: 0..40 (16)} paints the object in the Japanese style, as if with a wet brush full of black ink on rice paper: soft, blurred edges and rich blacks, the colours keeping their hue and the object its shape",
|
||||
json!({"strokeWidth": 10.0, "strokePressure": 2.0, "contrast": 16.0}),
|
||||
),
|
||||
r(
|
||||
"distort.diffuseGlow",
|
||||
"Diffuse Glow…",
|
||||
DISTORT,
|
||||
"{graininess: 0..10 (6; white grain, thicker in the glow), glowAmount: 0..20 (10), clearAmount: 0..20 (15; the higher, the more of the image stays clear of glow)} renders the object as if seen through a soft diffusion filter: its highlights glow white under see-through white grain",
|
||||
json!({"graininess": 6.0, "glowAmount": 10.0, "clearAmount": 15.0}),
|
||||
),
|
||||
r(
|
||||
"distort.glass",
|
||||
"Glass…",
|
||||
DISTORT,
|
||||
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\" (\"frosted\"; surfaces made in code), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass",
|
||||
json!({"distortion": 5.0, "smoothness": 3.0, "texture": "frosted", "scaling": 100.0, "invert": false}),
|
||||
),
|
||||
r(
|
||||
"distort.oceanRipple",
|
||||
"Ocean Ripple…",
|
||||
DISTORT,
|
||||
"{rippleSize: 1..15 (9), rippleMagnitude: 0..20 (9)} adds randomly spaced ripples, as if the object were under water",
|
||||
json!({"rippleSize": 9.0, "rippleMagnitude": 9.0}),
|
||||
),
|
||||
r(
|
||||
"sharpen.unsharpMask",
|
||||
"Unsharp Mask…",
|
||||
@@ -567,6 +661,7 @@ pub fn is_geometry(id: &str) -> bool {
|
||||
&& !is_pathfinder(id)
|
||||
&& !is_adjustment(id)
|
||||
&& id != CROP_MARKS
|
||||
&& id != REVOLVE
|
||||
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
//! Effect › Brush Strokes (Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines,
|
||||
//! Spatter, Sprayed Strokes, Sumi-e).
|
||||
//!
|
||||
//! The stroke filters repaint the object with brush strokes: each pixel takes the object's colour
|
||||
//! averaged along a segment through it ([`Smear`], a bounded number of samples whatever the
|
||||
//! length). Strokes lie in rows across their direction, each row starting at random and each
|
||||
//! stroke shifted along itself at random, so they begin and end like separate strokes; a streaked
|
||||
//! noise along them gives them their bristle texture. Accented Edges and Ink Outlines find edges
|
||||
//! with Glowing Edges' Sobel operator over the softened object; Spatter and Sprayed Strokes shift
|
||||
//! the content by noise as Ocean Ripple does. Every pattern lies in document space around the
|
||||
//! object's centre, as the Texture filters' do.
|
||||
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, TAU};
|
||||
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use super::distort::displace;
|
||||
use super::edges::sobel;
|
||||
use super::pixelate::{Place, cell, rand, value_noise};
|
||||
use super::texture::{Tones, luma, premultiply, smooth_noise, straight};
|
||||
use super::{PixelSpace, blur_plane, gaussian, pick, to16};
|
||||
|
||||
const SALT_ANGLED: u64 = 144;
|
||||
const SALT_CROSS: u64 = 152;
|
||||
const SALT_DARK: u64 = 160;
|
||||
const SALT_INK: u64 = 168;
|
||||
const SALT_SPATTER: u64 = 176;
|
||||
const SALT_SPRAYED: u64 = 184;
|
||||
const SALT_SUMI: u64 = 192;
|
||||
|
||||
/// Sprayed Strokes' stroke directions, in its Stroke Direction menu's order: (label, parameter
|
||||
/// value).
|
||||
pub const STROKE_DIRECTIONS: [(&str, &str); 4] =
|
||||
[("Right Diagonal", "rightDiagonal"), ("Horizontal", "horizontal"), ("Left Diagonal", "leftDiagonal"), ("Vertical", "vertical")];
|
||||
|
||||
/// The way Sprayed Strokes are painted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StrokeDirection {
|
||||
RightDiagonal,
|
||||
Horizontal,
|
||||
LeftDiagonal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
impl StrokeDirection {
|
||||
const ALL: [StrokeDirection; 4] =
|
||||
[StrokeDirection::RightDiagonal, StrokeDirection::Horizontal, StrokeDirection::LeftDiagonal, StrokeDirection::Vertical];
|
||||
|
||||
/// The direction of `value` (a [`STROKE_DIRECTIONS`] value, any case); Right Diagonal
|
||||
/// otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&STROKE_DIRECTIONS, &Self::ALL, value, StrokeDirection::RightDiagonal)
|
||||
}
|
||||
|
||||
/// The unit vector along the strokes (document axes, y down).
|
||||
fn along(self) -> Vec2 {
|
||||
match self {
|
||||
StrokeDirection::RightDiagonal => RIGHT,
|
||||
StrokeDirection::Horizontal => Vec2::new(1.0, 0.0),
|
||||
StrokeDirection::LeftDiagonal => LEFT,
|
||||
StrokeDirection::Vertical => Vec2::new(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Along a right diagonal stroke, rising to the right (y down).
|
||||
const RIGHT: Vec2 = Vec2::new(FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
|
||||
/// Along a left diagonal stroke, falling to the right.
|
||||
const LEFT: Vec2 = Vec2::new(FRAC_1_SQRT_2, FRAC_1_SQRT_2);
|
||||
|
||||
/// The most samples a stroke averages, whatever its length.
|
||||
const MAX_SAMPLES: usize = 16;
|
||||
|
||||
/// The share of its length by which a stroke is shifted along itself at random, at most.
|
||||
const JITTER: f64 = 0.25;
|
||||
|
||||
/// How soft (points, the σ) the tones are that decide which strokes a pixel gets.
|
||||
const TONE_SOFTNESS: f64 = 1.0;
|
||||
|
||||
/// Dark Strokes' short dark strokes and long light ones (points).
|
||||
pub(super) const DARK_LENGTH: f64 = 3.0;
|
||||
pub(super) const LIGHT_LENGTH: f64 = 9.0;
|
||||
|
||||
/// How soft (points, the σ) Ink Outlines' outlines are.
|
||||
const INK_SOFTNESS: f64 = 0.6;
|
||||
|
||||
/// A pixel at 72 ppi (points): how far the neighbours of the Sobel operator and of sampling
|
||||
/// between pixels add to a filter's reach.
|
||||
const MARGIN: f64 = 1.0;
|
||||
|
||||
/// How far (document units) strokes `len` long reach: half their length, their random shift, the
|
||||
/// softening between their samples and the pixels a sample is taken between.
|
||||
pub(super) fn stroke_reach(len: f64) -> f64 {
|
||||
let len = if len.is_finite() { len.max(0.0) } else { 0.0 };
|
||||
(0.5 + JITTER + 1.5 / (MAX_SAMPLES - 1) as f64) * len + MARGIN
|
||||
}
|
||||
|
||||
/// How far (document units) the tones reach that the stroke filters choose their strokes by.
|
||||
pub(super) const TONE_REACH: f64 = 3.0 * TONE_SOFTNESS + MARGIN;
|
||||
|
||||
/// How far Sumi-e reaches with strokes `width` across: the strokes, and the wet ink bleeding from
|
||||
/// 3 σ of 0.2 × the width.
|
||||
pub(super) fn sumi_reach(width: f64) -> f64 {
|
||||
stroke_reach(width).max(0.6 * width + MARGIN)
|
||||
}
|
||||
|
||||
/// The σ (points) Accented Edges softens the object by before finding its edges, for Edge Width
|
||||
/// `width` (1..14) and Smoothness `smoothness` (1..15).
|
||||
pub(super) fn accent_softness(width: f64, smoothness: f64) -> f64 {
|
||||
0.35 * width.clamp(1.0, 14.0) + 0.1 * smoothness.clamp(1.0, 15.0)
|
||||
}
|
||||
|
||||
/// How far Accented Edges reaches for Edge Width `width` and Smoothness `smoothness`.
|
||||
pub(super) fn accent_reach(width: f64, smoothness: f64) -> f64 {
|
||||
3.0 * accent_softness(width, smoothness) + MARGIN
|
||||
}
|
||||
|
||||
/// How far Ink Outlines reaches with strokes `len` long.
|
||||
pub(super) fn ink_reach(len: f64) -> f64 {
|
||||
stroke_reach(len).max(3.0 * INK_SOFTNESS + MARGIN)
|
||||
}
|
||||
|
||||
/// How far (points) Crosshatch roughens the edges, with strokes `len` long.
|
||||
pub(super) fn rough(len: f64) -> f64 {
|
||||
0.1 * if len.is_finite() { len.max(0.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The longest shift (points) Spatter gives at Spray Radius `radius` (0..25).
|
||||
pub(super) fn spatter_shift(radius: f64) -> f64 {
|
||||
0.4 * radius.clamp(0.0, 25.0)
|
||||
}
|
||||
|
||||
/// The longest shift (points) Sprayed Strokes' spray gives at Spray Radius `radius` (0..25).
|
||||
pub(super) fn spray_shift(radius: f64) -> f64 {
|
||||
0.3 * radius.clamp(0.0, 25.0)
|
||||
}
|
||||
|
||||
/// The object's colours averaged along strokes `len` long: [`MAX_SAMPLES`] at most, the object
|
||||
/// softened first when they lie more than a pixel apart.
|
||||
struct Smear<'a> {
|
||||
/// Premultiplied colours (0..1).
|
||||
src: Vec<[f32; 4]>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
place: &'a Place,
|
||||
len: f64,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl<'a> Smear<'a> {
|
||||
fn new(px: &[[u8; 4]], w: usize, h: usize, place: &'a Place, len: f64) -> Self {
|
||||
let len = if len.is_finite() { len.max(0.0) } else { 0.0 };
|
||||
let span = len / place.px;
|
||||
let n = if span.is_finite() { (span.ceil() as usize).saturating_add(1).clamp(1, MAX_SAMPLES) } else { MAX_SAMPLES };
|
||||
let gap = if n > 1 { span / (n - 1) as f64 } else { 0.0 };
|
||||
let mut wide = to16(px);
|
||||
if gap > 1.0 {
|
||||
gaussian(&mut wide, w, 0.5 * gap);
|
||||
}
|
||||
let src = wide.iter().map(|p| p.map(|v| f32::from(v) / 65535.0)).collect();
|
||||
Self { src, w, h, place, len, n }
|
||||
}
|
||||
|
||||
/// The premultiplied colour (0..1) averaged along the stroke through `centre` (relative to the
|
||||
/// object's centre) along `dir`.
|
||||
fn at(&self, centre: Point, dir: Vec2) -> [f32; 4] {
|
||||
let half = dir * (0.5 * self.len);
|
||||
let (a, b) = (self.place.to_px * (centre - half), self.place.to_px * (centre + half));
|
||||
if !(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) {
|
||||
return [0.0; 4];
|
||||
}
|
||||
// Pixel centres at whole coordinates.
|
||||
let (mut x, mut y, step) = match self.n {
|
||||
0 | 1 => (0.5 * (a.x + b.x) - 0.5, 0.5 * (a.y + b.y) - 0.5, (0.0, 0.0)),
|
||||
n => (a.x - 0.5, a.y - 0.5, ((b.x - a.x) / (n - 1) as f64, (b.y - a.y) / (n - 1) as f64)),
|
||||
};
|
||||
let mut acc = [0.0f32; 4];
|
||||
for _ in 0..self.n {
|
||||
self.add(&mut acc, x, y);
|
||||
x += step.0;
|
||||
y += step.1;
|
||||
}
|
||||
acc.map(|v| v / self.n.max(1) as f32)
|
||||
}
|
||||
|
||||
/// Add the colour at (`x`, `y`) (pixel centres at whole coordinates), interpolated between the
|
||||
/// four pixels around it, to `acc`; transparent outside.
|
||||
fn add(&self, acc: &mut [f32; 4], x: f64, y: f64) {
|
||||
let (x0, y0) = (x.floor(), y.floor());
|
||||
if !(x0 >= -1.0 && y0 >= -1.0 && x0 < self.w as f64 && y0 < self.h as f64) {
|
||||
return;
|
||||
}
|
||||
let (fx, fy) = ((x - x0) as f32, (y - y0) as f32);
|
||||
let (i, j) = (x0 as isize, y0 as isize);
|
||||
// Inside the raster: the four pixels as two pairs.
|
||||
if i >= 0 && j >= 0 && (i as usize) + 1 < self.w && (j as usize) + 1 < self.h {
|
||||
let at = j as usize * self.w + i as usize;
|
||||
if let (Some([p00, p10]), Some([p01, p11])) = (self.src.get(at..at + 2), self.src.get(at + self.w..at + self.w + 2)) {
|
||||
let (k00, k10, k01, k11) = ((1.0 - fx) * (1.0 - fy), fx * (1.0 - fy), (1.0 - fx) * fy, fx * fy);
|
||||
for (c, a) in acc.iter_mut().enumerate() {
|
||||
*a += p00[c] * k00 + p10[c] * k10 + p01[c] * k01 + p11[c] * k11;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (row, wy) in [(j, 1.0 - fy), (j + 1, fy)] {
|
||||
let Some(row) = usize::try_from(row).ok().filter(|r| *r < self.h) else { continue };
|
||||
for (col, wx) in [(i, 1.0 - fx), (i + 1, fx)] {
|
||||
let Some(col) = usize::try_from(col).ok().filter(|c| *c < self.w) else { continue };
|
||||
if let Some(p) = self.src.get(row * self.w + col) {
|
||||
let k = wx * wy;
|
||||
for (a, v) in acc.iter_mut().zip(p) {
|
||||
*a += v * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stroke of pattern `salt` through `q` along `dir`, its rows `width` apart.
|
||||
fn stroke(&self, salt: u64, q: Point, dir: Vec2, width: f64) -> [f32; 4] {
|
||||
self.at(q + dir * stroke_shift(salt, q, dir, self.len, width), dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// `q`'s coordinates along `dir` (unit) and across it.
|
||||
fn frame(q: Point, dir: Vec2) -> (f64, f64) {
|
||||
(q.x * dir.x + q.y * dir.y, q.y * dir.x - q.x * dir.y)
|
||||
}
|
||||
|
||||
/// How far along `dir` the stroke through `q` is shifted: strokes `len` long lie in rows `width`
|
||||
/// apart across `dir`, each row starting at random, each stroke shifted by up to [`JITTER`] ×
|
||||
/// `len`.
|
||||
fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 {
|
||||
if !(len > 0.0 && width > 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
let (u, v) = frame(q, dir);
|
||||
let Some(row) = cell(v / width) else { return 0.0 };
|
||||
let Some(col) = cell(u / len + rand(salt, row, 0, 0)) else { return 0.0 };
|
||||
(2.0 * rand(salt, col, row, 1) - 1.0) * JITTER * len
|
||||
}
|
||||
|
||||
/// Smooth noise in [-1, 1) streaked along `dir`: blobs `along` long and `across` wide.
|
||||
fn streak(salt: u64, q: Point, dir: Vec2, along: f64, across: f64) -> f64 {
|
||||
let (u, v) = frame(q, dir);
|
||||
2.0 * value_noise(salt, u / along.max(1e-3), v / across.max(1e-3)) - 1.0
|
||||
}
|
||||
|
||||
/// The straight colour and coverage of premultiplied `pm` (0..1).
|
||||
fn unpremultiply(pm: [f32; 4]) -> ([f32; 3], f32) {
|
||||
let a = pm[3].clamp(0.0, 1.0);
|
||||
if a > 1e-6 { ([pm[0], pm[1], pm[2]].map(|v| (v / a).clamp(0.0, 1.0)), a) } else { ([0.0; 3], 0.0) }
|
||||
}
|
||||
|
||||
/// `a` + (`b` − `a`) × `t`, channel by channel.
|
||||
fn mix<const N: usize>(a: [f32; N], b: [f32; N], t: f32) -> [f32; N] {
|
||||
std::array::from_fn(|k| a[k] + (b[k] - a[k]) * t)
|
||||
}
|
||||
|
||||
/// A smooth step from 0 at `t` ≤ 0 to 1 at `t` ≥ 1.
|
||||
fn ramp(t: f64) -> f64 {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Pixel `p` (premultiplied 0..255) as premultiplied 0..1.
|
||||
fn unit(p: [u8; 4]) -> [f32; 4] {
|
||||
p.map(|v| f32::from(v) / 255.0)
|
||||
}
|
||||
|
||||
/// The object's edges after softening it by `sigma` (document units): about 1 across a step from
|
||||
/// black to white or from clear to opaque, the same at any resolution. Empty when the sizes don't
|
||||
/// match.
|
||||
fn edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
|
||||
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
|
||||
let mut cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let s = sigma / place.px;
|
||||
blur_plane(&mut tone, w, h, s);
|
||||
blur_plane(&mut cover, w, h, s);
|
||||
// A step softened by σ pixels rises by 1 / (σ √(2π)) per pixel at most, and Sobel gives 8 ×
|
||||
// the slope; a pixel is the least it softens by.
|
||||
let norm = (s.max(0.8) * TAU.sqrt() / 8.0) as f32;
|
||||
let norm = if norm.is_finite() { norm } else { 0.0 };
|
||||
sobel(&tone, w, h).iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c) * norm).collect()
|
||||
}
|
||||
|
||||
/// Accented Edges: the object's edges accented in white chalk when `brightness` (0..50) is high,
|
||||
/// in black ink when it's low, about `width` (1..14) wide; `smoothness` (1..15) softens the object
|
||||
/// first and leaves out its fainter edges.
|
||||
pub(super) fn accented_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, brightness: f64, smoothness: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let found = edges(px, w, h, &place, accent_softness(width, smoothness));
|
||||
let faint = (0.02 * smoothness.clamp(1.0, 15.0)) as f32;
|
||||
let level = (brightness / 50.0).clamp(0.0, 1.0) as f32;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let e = found.get(y * w + x).copied().unwrap_or(0.0);
|
||||
let e = (2.5 * (e - faint) / (1.0 - faint)).clamp(0.0, 1.0);
|
||||
let c = straight(*p);
|
||||
// Chalk or ink of the brightness level, a little of the colour showing through.
|
||||
let accent = c.map(|v| 0.9 * level + 0.1 * v);
|
||||
*p = premultiply(mix(c, accent, e), f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Angled Strokes: the object repainted in diagonal strokes `len` long, light areas in right
|
||||
/// diagonal strokes and dark areas in left diagonal ones; `balance` (0..100) moves the split
|
||||
/// (0 all left diagonal, 100 all right diagonal) and `sharpness` (0..10) brings the detail and
|
||||
/// the bristles out.
|
||||
pub(super) fn angled_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, len: f64, sharpness: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let smear = Smear::new(px, w, h, &place, len);
|
||||
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
|
||||
let split = 1.07 - 1.14 * (balance / 100.0).clamp(0.0, 1.0);
|
||||
let sharp = sharpness.clamp(0.0, 10.0);
|
||||
let (detail, bristle) = ((0.05 * sharp) as f32, 0.03 + 0.012 * sharp);
|
||||
let row = (0.2 * smear.len).max(1.0);
|
||||
let src = px.to_vec();
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
// The split between the directions wanders along the strokes, so they interlock.
|
||||
let right = tones.get(x, y) > split + 0.06 * streak(SALT_ANGLED + 3, q, RIGHT, row, row);
|
||||
let (dir, salt) = if right { (RIGHT, SALT_ANGLED) } else { (LEFT, SALT_ANGLED + 1) };
|
||||
let pm = smear.stroke(salt, q, dir, row);
|
||||
let pm = mix(pm, unit(src.get(y * w + x).copied().unwrap_or([0; 4])), detail);
|
||||
let (c, a) = unpremultiply(pm);
|
||||
let n = (bristle * streak(SALT_ANGLED + 2, q, dir, 0.5 * smear.len.max(1.0), 0.6)) as f32;
|
||||
*p = premultiply(c.map(|v| v + n), a);
|
||||
});
|
||||
}
|
||||
|
||||
/// Crosshatch: the object's detail kept under pencil hatching along both diagonals, strokes `len`
|
||||
/// long, roughening its edges; `sharpness` (0..20) sets how crisp and dark the hatching is,
|
||||
/// `passes` (1..3) how many layers of it there are.
|
||||
pub(super) fn crosshatch(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, sharpness: f64, passes: u32) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let smear = Smear::new(px, w, h, &place, len);
|
||||
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
|
||||
let sharp = sharpness.clamp(0.0, 20.0);
|
||||
let (amount, crisp) = (0.05 + 0.01 * sharp, 1.0 + 0.3 * sharp);
|
||||
let row = (0.2 * smear.len).max(1.0);
|
||||
let roughen = rough(smear.len);
|
||||
let src = px.to_vec();
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let line = |k: u32| {
|
||||
let dir = if k.is_multiple_of(2) { RIGHT } else { LEFT };
|
||||
streak(SALT_CROSS + u64::from(k), q, dir, smear.len.max(1.0), 0.7 + 0.3 * f64::from(k))
|
||||
};
|
||||
let first = line(0);
|
||||
// The edges roughened along the first hatching.
|
||||
let o = q + RIGHT * (roughen * first);
|
||||
let strokes = mix(smear.stroke(SALT_CROSS + 4, o, RIGHT, row), smear.stroke(SALT_CROSS + 5, o, LEFT, row), 0.5);
|
||||
let pm = mix(unit(src.get(y * w + x).copied().unwrap_or([0; 4])), strokes, 0.5);
|
||||
let (mut c, a) = unpremultiply(pm);
|
||||
if a <= 0.0 {
|
||||
*p = [0; 4];
|
||||
return;
|
||||
}
|
||||
let shade = 1.3 - tones.get(x, y);
|
||||
for k in 0..passes.clamp(1, 3) {
|
||||
let v = (if k == 0 { first } else { line(k) } * crisp).clamp(-1.0, 1.0);
|
||||
// Dark pencil lines and the light paper between them.
|
||||
let s = if v > 0.0 { -(amount * v * shade) } else { -0.6 * amount * v };
|
||||
let s = s.clamp(-1.0, 1.0) as f32;
|
||||
c = c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s });
|
||||
}
|
||||
*p = premultiply(c, a);
|
||||
});
|
||||
}
|
||||
|
||||
/// Dark Strokes: dark areas painted closer to black with short strokes, light areas with long
|
||||
/// strokes closer to white; `balance` (0..10) moves the tone between them up, `black` (0..10) and
|
||||
/// `white` (0..10) set how dark and how light they get.
|
||||
pub(super) fn dark_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, black: f64, white: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let short = Smear::new(px, w, h, &place, DARK_LENGTH);
|
||||
let long = Smear::new(px, w, h, &place, LIGHT_LENGTH);
|
||||
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
|
||||
let split = 0.15 + 0.07 * balance.clamp(0.0, 10.0);
|
||||
let (black, white) = (0.085 * black.clamp(0.0, 10.0), 0.085 * white.clamp(0.0, 10.0));
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let dark = 1.0 - ramp((tones.get(x, y) - split) / 0.12 + 0.5);
|
||||
let painted = |light: bool| {
|
||||
let (smear, dir, salt) = if light { (&long, RIGHT, SALT_DARK) } else { (&short, LEFT, SALT_DARK + 1) };
|
||||
let (c, a) = unpremultiply(smear.stroke(salt, q, dir, 1.0));
|
||||
let n = 0.7 + 0.3 * streak(salt + 2, q, dir, 0.5 * smear.len, 0.5);
|
||||
let c = if light {
|
||||
let s = (white * n).clamp(0.0, 1.0) as f32;
|
||||
c.map(|v| v + (1.0 - v) * s)
|
||||
} else {
|
||||
let s = (black * n).clamp(0.0, 1.0) as f32;
|
||||
c.map(|v| v * (1.0 - s))
|
||||
};
|
||||
[c[0] * a, c[1] * a, c[2] * a, a]
|
||||
};
|
||||
let pm = if dark >= 1.0 {
|
||||
painted(false)
|
||||
} else if dark <= 0.0 {
|
||||
painted(true)
|
||||
} else {
|
||||
mix(painted(true), painted(false), dark as f32)
|
||||
};
|
||||
let (c, a) = unpremultiply(pm);
|
||||
*p = premultiply(c, a);
|
||||
});
|
||||
}
|
||||
|
||||
/// Ink Outlines: the object redrawn in pen and ink, fine outlines over its edges and ink strokes
|
||||
/// `len` long in its shadows; `dark` (0..50) darkens the shadows, `light` (0..50) lightens the
|
||||
/// highlights.
|
||||
pub(super) fn ink_outlines(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, dark: f64, light: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let smear = Smear::new(px, w, h, &place, len);
|
||||
let found = edges(px, w, h, &place, INK_SOFTNESS);
|
||||
let (dark, light) = ((dark / 50.0).clamp(0.0, 1.0), (light / 50.0).clamp(0.0, 1.0));
|
||||
let row = (0.25 * smear.len).max(0.75);
|
||||
let src = px.to_vec();
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let pm = mix(unit(src.get(y * w + x).copied().unwrap_or([0; 4])), smear.stroke(SALT_INK, q, LEFT, row), 0.5);
|
||||
let (c, a) = unpremultiply(pm);
|
||||
if a <= 0.0 {
|
||||
*p = [0; 4];
|
||||
return;
|
||||
}
|
||||
let l = f64::from(luma(c));
|
||||
let (shadow, highlight) = (((0.75 - l) / 0.75).clamp(0.0, 1.0), ((l - 0.45) / 0.55).clamp(0.0, 1.0));
|
||||
let (down, up) = ((0.7 * dark * shadow) as f32, (0.7 * light * highlight) as f32);
|
||||
let c = c.map(|v| v * (1.0 - down)).map(|v| v + (1.0 - v) * up);
|
||||
// Ink lines along the strokes in the shadows, and the outlines.
|
||||
let hatch = ramp(2.0 * streak(SALT_INK + 1, q, LEFT, 1.5 * smear.len.max(1.0), 0.5) + 0.2) * shadow * (0.4 + 1.2 * dark);
|
||||
let outline = (1.5 * f64::from(found.get(y * w + x).copied().unwrap_or(0.0)) - 0.15).clamp(0.0, 1.0);
|
||||
let ink = (0.9 * outline).max(0.8 * hatch).min(1.0) as f32;
|
||||
*p = premultiply(c.map(|v| v * (1.0 - ink)), a);
|
||||
});
|
||||
}
|
||||
|
||||
/// Spatter: the object as if sprayed with a spatter airbrush, each pixel taking the colour of one
|
||||
/// up to [`spatter_shift`] away; `smoothness` (1..15) makes the spatter's grains larger and
|
||||
/// smoother.
|
||||
pub(super) fn spatter(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, radius: f64, smoothness: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let grain = 0.3 + 0.15 * smoothness.clamp(1.0, 15.0);
|
||||
let most = spatter_shift(radius);
|
||||
displace(px, w, h, &place, most, |q| {
|
||||
let n = |salt: u64| 0.6 * smooth_noise(salt, q, grain) + 0.4 * smooth_noise(salt + 2, q, 0.4 * grain);
|
||||
Vec2::new(n(SALT_SPATTER), n(SALT_SPATTER + 1)) * (1.8 * most)
|
||||
});
|
||||
}
|
||||
|
||||
/// Sprayed Strokes: the object repainted in sprayed strokes `len` long along `dir`, scattered by
|
||||
/// up to [`spray_shift`].
|
||||
pub(super) fn sprayed_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, radius: f64, dir: StrokeDirection) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let smear = Smear::new(px, w, h, &place, len);
|
||||
let (dir, spray) = (dir.along(), spray_shift(radius));
|
||||
let along = (0.5 * smear.len).max(0.5);
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let n = |salt: u64| streak(salt, q, dir, along, 0.5);
|
||||
let mut d = Vec2::new(n(SALT_SPRAYED), n(SALT_SPRAYED + 1)) * (1.6 * spray);
|
||||
let l = d.hypot();
|
||||
if l > spray && l.is_finite() {
|
||||
d *= spray / l;
|
||||
}
|
||||
let (c, a) = unpremultiply(smear.stroke(SALT_SPRAYED + 2, q + d, dir, (0.2 * smear.len).max(1.0)));
|
||||
*p = premultiply(c, a);
|
||||
});
|
||||
}
|
||||
|
||||
/// Sumi-e: the object as if painted with a wet brush on rice paper, in strokes `width` (3..15)
|
||||
/// across with soft, bleeding edges and rich blacks; `pressure` (0..15) loads the brush with more
|
||||
/// ink and `contrast` (0..40) sets the contrast. It works in the ink (the value): the colours keep
|
||||
/// their hue and saturation, and the object keeps its shape.
|
||||
pub(super) fn sumi_e(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, pressure: f64, contrast: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let smear = Smear::new(px, w, h, &place, width);
|
||||
// The ink around each pixel: the object's darkness by its coverage, softened.
|
||||
let mut wet: Vec<f32> = px.iter().map(|p| (1.0 - luma(straight(*p))) * f32::from(p[3]) / 255.0).collect();
|
||||
blur_plane(&mut wet, w, h, 0.2 * smear.len / place.px);
|
||||
let load = 1.0 + 0.06 * pressure.clamp(0.0, 15.0);
|
||||
// How far the ink moves along an S curve: up to once at 20, twice at 40.
|
||||
let gain = contrast.clamp(0.0, 40.0) / 20.0;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let (c, a) = unpremultiply(smear.stroke(SALT_SUMI, q, LEFT, (0.3 * smear.len).max(1.0)));
|
||||
// Where the stroke brings no paint, the pixel's own colour.
|
||||
let c = if a > 0.0 { c } else { straight(*p) };
|
||||
let l = f64::from(luma(c));
|
||||
// The ink bleeds into the paper around the darks, along its fibres.
|
||||
let bleed = 0.85 * f64::from(wet.get(y * w + x).copied().unwrap_or(0.0));
|
||||
let fibre = 1.0 + 0.1 * streak(SALT_SUMI + 1, q, Vec2::new(1.0, 0.0), 2.0, 0.35);
|
||||
let mut ink = ((1.0 - l).max(bleed) * load * fibre).clamp(0.0, 1.0);
|
||||
let mut left = gain;
|
||||
while left > 0.0 {
|
||||
ink += (ramp(ink) - ink) * left.min(1.0);
|
||||
left -= 1.0;
|
||||
}
|
||||
let tone = (1.0 - ink).clamp(0.0, 1.0);
|
||||
// Darker towards black, lighter towards the paper's white: the hue stays.
|
||||
let out = if tone <= l {
|
||||
let k = if l > 1e-3 { (tone / l) as f32 } else { 0.0 };
|
||||
c.map(|v| v * k)
|
||||
} else {
|
||||
let k = ((tone - l) / (1.0 - l).max(1e-3)).clamp(0.0, 1.0) as f32;
|
||||
c.map(|v| v + (1.0 - v) * k)
|
||||
};
|
||||
*p = premultiply(out, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Effect › Distort (Diffuse Glow, Glass, Ocean Ripple).
|
||||
//!
|
||||
//! Glass and Ocean Ripple move each pixel's content by a shift that a surface decides (the slope
|
||||
//! of Glass's texture, smooth noise for the ripples), sampling the object bilinearly. The shift is
|
||||
//! capped, so the object reaches no farther than [`glass_shift`] or [`ripple_shift`] past its
|
||||
//! bounds. Diffuse Glow brightens the object's highlights towards white and sprinkles it with
|
||||
//! white grain. Every surface lies in document space around the object's centre, as the Texture
|
||||
//! filters' do.
|
||||
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use super::pixelate::{Place, value_noise};
|
||||
use super::texture::{Texture, cell_noise, luma, premultiply, smooth_noise, straight};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
|
||||
const SALT_GLOW: u64 = 120;
|
||||
const SALT_FROST: u64 = 128;
|
||||
const SALT_RIPPLE: u64 = 136;
|
||||
|
||||
/// Glass's surfaces, in its Texture menu's order: (label, parameter value).
|
||||
pub const GLASS_TEXTURES: [(&str, &str); 4] = [("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens")];
|
||||
|
||||
/// A Glass surface.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GlassTexture {
|
||||
Blocks,
|
||||
Canvas,
|
||||
Frosted,
|
||||
TinyLens,
|
||||
}
|
||||
|
||||
impl GlassTexture {
|
||||
const ALL: [GlassTexture; 4] = [GlassTexture::Blocks, GlassTexture::Canvas, GlassTexture::Frosted, GlassTexture::TinyLens];
|
||||
|
||||
/// The surface of `value` (a [`GLASS_TEXTURES`] value, any case); Frosted otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&GLASS_TEXTURES, &Self::ALL, value, GlassTexture::Frosted)
|
||||
}
|
||||
|
||||
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
|
||||
fn height(self, p: Point) -> f64 {
|
||||
match self {
|
||||
GlassTexture::Blocks => blocks(p),
|
||||
GlassTexture::Canvas => Texture::Canvas.height(p),
|
||||
GlassTexture::Frosted => frosted(p),
|
||||
GlassTexture::TinyLens => tiny_lens(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Square glass blocks 10 points across, flat on top with 2.5-point bevels.
|
||||
fn blocks(p: Point) -> f64 {
|
||||
const SIZE: f64 = 10.0;
|
||||
const BEVEL: f64 = 2.5;
|
||||
let to_edge = |v: f64| {
|
||||
let t = v.rem_euclid(SIZE);
|
||||
t.min(SIZE - t)
|
||||
};
|
||||
to_edge(p.x).min(to_edge(p.y)).min(BEVEL)
|
||||
}
|
||||
|
||||
/// Frosted glass: two octaves of fine smooth noise (1.2 and 0.5 points).
|
||||
fn frosted(p: Point) -> f64 {
|
||||
0.8 * value_noise(SALT_FROST, p.x / 1.2, p.y / 1.2) + 0.2 * value_noise(SALT_FROST + 1, p.x / 0.5, p.y / 0.5)
|
||||
}
|
||||
|
||||
/// Tiny lenses 4 points across in rows and columns, each a shallow dome.
|
||||
fn tiny_lens(p: Point) -> f64 {
|
||||
const SIZE: f64 = 4.0;
|
||||
let r = 0.5 * SIZE;
|
||||
let off = |v: f64| v.rem_euclid(SIZE) - r;
|
||||
let d2 = off(p.x).powi(2) + off(p.y).powi(2);
|
||||
0.5 * r * (1.0 - d2 / (r * r)).max(0.0)
|
||||
}
|
||||
|
||||
/// The longest shift (points) Glass gives at `distortion` (0..20).
|
||||
pub(super) fn glass_shift(distortion: f64) -> f64 {
|
||||
0.5 * distortion.clamp(0.0, 20.0)
|
||||
}
|
||||
|
||||
/// The ripples' size (points) for Ripple Size `size` (1..15).
|
||||
fn ripple_wave(size: f64) -> f64 {
|
||||
2.0 + size.clamp(1.0, 15.0)
|
||||
}
|
||||
|
||||
/// The longest shift (points) Ocean Ripple gives for Ripple Size `size` (1..15) and Ripple
|
||||
/// Magnitude `magnitude` (0..20).
|
||||
pub(super) fn ripple_shift(size: f64, magnitude: f64) -> f64 {
|
||||
0.03 * magnitude.clamp(0.0, 20.0) * ripple_wave(size)
|
||||
}
|
||||
|
||||
/// How far (points) Diffuse Glow spreads the highlights (the σ of their blur).
|
||||
pub(super) const GLOW_SPREAD: f64 = 2.0;
|
||||
|
||||
/// Glass: the object seen through a `texture` surface scaled by `scaling` (0.5..2), bent by
|
||||
/// `distortion` (0..20) and smoothed by `smoothness` (1..15); `invert` turns the surface's heights
|
||||
/// over.
|
||||
#[allow(clippy::too_many_arguments)] // the effect's five options
|
||||
pub(super) fn glass(
|
||||
px: &mut [[u8; 4]],
|
||||
w: usize,
|
||||
h: usize,
|
||||
space: &PixelSpace,
|
||||
distortion: f64,
|
||||
smoothness: f64,
|
||||
texture: GlassTexture,
|
||||
scaling: f64,
|
||||
invert: bool,
|
||||
) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(scaling.is_finite() && scaling > 0.0 && smoothness.is_finite()) {
|
||||
return;
|
||||
}
|
||||
// Slopes over a quarter point of the surface per step of Smoothness, or half a pixel where
|
||||
// pixels are larger: a smoother glass bends the image more gently.
|
||||
let step = (0.25 * smoothness.clamp(1.0, 15.0)).max(0.5 * place.px / scaling);
|
||||
let gain = 0.25 * distortion.clamp(0.0, 20.0) * if invert { -1.0 } else { 1.0 };
|
||||
displace(px, w, h, &place, glass_shift(distortion), |q| {
|
||||
let t = Point::new(q.x / scaling, q.y / scaling);
|
||||
let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy));
|
||||
Vec2::new(at(step, 0.0) - at(-step, 0.0), at(0.0, step) - at(0.0, -step)) * (gain / (2.0 * step))
|
||||
});
|
||||
}
|
||||
|
||||
/// Ocean Ripple: the object as if under rippling water, ripples about `size` (1..15) points plus
|
||||
/// two across, shifting it by up to [`ripple_shift`].
|
||||
pub(super) fn ocean_ripple(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, magnitude: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let (wave, most) = (ripple_wave(size), ripple_shift(size, magnitude));
|
||||
displace(px, w, h, &place, most, |q| {
|
||||
let n = |salt: u64| 0.7 * smooth_noise(salt, q, wave) + 0.3 * smooth_noise(salt + 2, q, 0.5 * wave);
|
||||
Vec2::new(n(SALT_RIPPLE), n(SALT_RIPPLE + 1)) * most
|
||||
});
|
||||
}
|
||||
|
||||
/// Diffuse Glow: the object as seen through a soft diffusion filter. Its highlights, softened over
|
||||
/// [`GLOW_SPREAD`], glow white as strongly as `glow` (0..20) says, from the brightness that
|
||||
/// `clear` (0..20) leaves clear of glow; white grain as dense as `graininess` (0..10) says is
|
||||
/// sprinkled over it, thicker in the glow.
|
||||
pub(super) fn diffuse_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, graininess: f64, glow: f64, clear: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let mut light: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
|
||||
blur_plane(&mut light, w, h, GLOW_SPREAD / place.px);
|
||||
let amount = (glow / 20.0).clamp(0.0, 1.0) as f32;
|
||||
let from = (0.9 * clear / 20.0).clamp(0.0, 0.9) as f32;
|
||||
let grain = (graininess / 10.0).clamp(0.0, 1.0);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let g = ((light.get(y * w + x).copied().unwrap_or(0.0) - from) / (1.0 - from)).clamp(0.0, 1.0);
|
||||
let chance = 0.5 * (cell_noise(SALT_GLOW, place.at(x, y), 1.0, 0) + 1.0);
|
||||
let dot = if chance < grain * (0.08 + 0.32 * f64::from(g)) { 0.55 } else { 0.0 };
|
||||
let white = (amount * g + dot).min(1.0);
|
||||
*p = premultiply(straight(*p).map(|v| v + (1.0 - v) * white), f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Move the object's content by `shift(q)` (document units; `q` a pixel's centre relative to the
|
||||
/// object's centre, its length capped at `most`): each pixel takes the colour `shift` away from it,
|
||||
/// interpolated between pixels, transparent beyond the raster.
|
||||
pub(super) fn displace(px: &mut [[u8; 4]], w: usize, h: usize, place: &Place, most: f64, shift: impl Fn(Point) -> Vec2) {
|
||||
if !(most.is_finite() && most > 0.0) {
|
||||
return;
|
||||
}
|
||||
let src = px.to_vec();
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let d = shift(q);
|
||||
if !(d.x.is_finite() && d.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let len = d.hypot();
|
||||
let d = if len > most { d * (most / len) } else { d };
|
||||
let s = place.to_px * (q + d);
|
||||
*p = sample(&src, w, h, s.x - 0.5, s.y - 0.5);
|
||||
});
|
||||
}
|
||||
|
||||
/// The premultiplied colour at (`x`, `y`) of `w` × `h` pixels (pixel centres at whole
|
||||
/// coordinates), interpolated between the four around it, transparent outside.
|
||||
fn sample(src: &[[u8; 4]], w: usize, h: usize, x: f64, y: f64) -> [u8; 4] {
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
return [0; 4];
|
||||
}
|
||||
let (x0, y0) = (x.floor(), y.floor());
|
||||
let (fx, fy) = (x - x0, y - y0);
|
||||
let at = |i: f64, j: f64| -> [f64; 4] {
|
||||
if i < 0.0 || j < 0.0 || i >= w as f64 || j >= h as f64 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
src.get(j as usize * w + i as usize).map_or([0.0; 4], |p| p.map(f64::from))
|
||||
};
|
||||
let (a, b, c, d) = (at(x0, y0), at(x0 + 1.0, y0), at(x0, y0 + 1.0), at(x0 + 1.0, y0 + 1.0));
|
||||
std::array::from_fn(|k| {
|
||||
let top = a[k] + (b[k] - a[k]) * fx;
|
||||
let bottom = c[k] + (d[k] - c[k]) * fx;
|
||||
(top + (bottom - top) * fy).round().clamp(0.0, 255.0) as u8
|
||||
})
|
||||
}
|
||||
@@ -21,38 +21,20 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes
|
||||
if alpha > 0.0 { [f32::from(p[0]) / 255.0 / alpha, f32::from(p[1]) / 255.0 / alpha, f32::from(p[2]) / 255.0 / alpha] } else { [0.0; 3] };
|
||||
(straight, alpha)
|
||||
};
|
||||
let signal = |x: isize, y: isize| {
|
||||
let (rgb, alpha) = pixel(x, y);
|
||||
(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha
|
||||
};
|
||||
let mut edges = vec![0.0f32; px.len()];
|
||||
let signal: Vec<f32> = (0..px.len())
|
||||
.map(|i| {
|
||||
let (rgb, alpha) = pixel((i % w) as isize, (i / w) as isize);
|
||||
(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha
|
||||
})
|
||||
.collect();
|
||||
let coverage: Vec<f32> = src.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let mut edges: Vec<f32> = sobel(&signal, w, h).iter().zip(sobel(&coverage, w, h)).map(|(s, a)| s.max(a).clamp(0.0, 1.0)).collect();
|
||||
let mut colours: [Vec<f32>; 3] = std::array::from_fn(|_| vec![0.0; px.len()]);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let x = x as isize;
|
||||
let y = y as isize;
|
||||
let gx = -signal(x - 1, y - 1) + signal(x + 1, y - 1) - 2.0 * signal(x - 1, y) + 2.0 * signal(x + 1, y) - signal(x - 1, y + 1)
|
||||
+ signal(x + 1, y + 1);
|
||||
let gy = -signal(x - 1, y - 1) - 2.0 * signal(x, y - 1) - signal(x + 1, y - 1)
|
||||
+ signal(x - 1, y + 1)
|
||||
+ 2.0 * signal(x, y + 1)
|
||||
+ signal(x + 1, y + 1);
|
||||
let alpha_signal = |x, y| pixel(x, y).1;
|
||||
let agx = -alpha_signal(x - 1, y - 1) + alpha_signal(x + 1, y - 1) - 2.0 * alpha_signal(x - 1, y) + 2.0 * alpha_signal(x + 1, y)
|
||||
- alpha_signal(x - 1, y + 1)
|
||||
+ alpha_signal(x + 1, y + 1);
|
||||
let agy = -alpha_signal(x - 1, y - 1) - 2.0 * alpha_signal(x, y - 1) - alpha_signal(x + 1, y - 1)
|
||||
+ alpha_signal(x - 1, y + 1)
|
||||
+ 2.0 * alpha_signal(x, y + 1)
|
||||
+ alpha_signal(x + 1, y + 1);
|
||||
let index = y as usize * w + x as usize;
|
||||
let edge = gx.hypot(gy).max(agx.hypot(agy)).clamp(0.0, 1.0);
|
||||
if let Some(value) = edges.get_mut(index) {
|
||||
*value = edge;
|
||||
let (rgb, alpha) = pixel(x, y);
|
||||
for channel in 0..3 {
|
||||
colours[channel][index] = rgb[channel] * alpha * edge;
|
||||
}
|
||||
for (index, edge) in edges.iter().enumerate() {
|
||||
let (rgb, alpha) = pixel((index % w) as isize, (index / w) as isize);
|
||||
for (colour, v) in colours.iter_mut().zip(rgb) {
|
||||
if let Some(c) = colour.get_mut(index) {
|
||||
*c = v * alpha * edge;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,3 +67,25 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// The Sobel gradient's length over a `w` × `h` plane of values (zero beyond its edges): 8 × the
|
||||
/// slope per pixel on a ramp, 4 × the step at a sharp edge. Empty when the sizes don't match.
|
||||
pub(super) fn sobel(v: &[f32], w: usize, h: usize) -> Vec<f32> {
|
||||
if w == 0 || w.checked_mul(h) != Some(v.len()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let at = |x: usize, dx: isize, y: usize, dy: isize| -> f32 {
|
||||
match (x.checked_add_signed(dx), y.checked_add_signed(dy)) {
|
||||
(Some(x), Some(y)) if x < w && y < h => v.get(y * w + x).copied().unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
(0..v.len())
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let gx = at(x, 1, y, -1) - at(x, -1, y, -1) + 2.0 * (at(x, 1, y, 0) - at(x, -1, y, 0)) + at(x, 1, y, 1) - at(x, -1, y, 1);
|
||||
let gy = at(x, -1, y, 1) - at(x, -1, y, -1) + 2.0 * (at(x, 0, y, 1) - at(x, 0, y, -1)) + at(x, 1, y, 1) - at(x, 1, y, -1);
|
||||
gx.hypot(gy)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate › Color
|
||||
//! Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing
|
||||
//! Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and
|
||||
//! Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes ›
|
||||
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
|
||||
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
|
||||
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture
|
||||
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
|
||||
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||
//!
|
||||
//! - Every distance is in document units (points) and becomes pixels through the raster's
|
||||
//! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects
|
||||
@@ -11,6 +13,8 @@
|
||||
//! - Beyond the raster's edges is transparency.
|
||||
|
||||
mod blur;
|
||||
mod brushstrokes;
|
||||
mod distort;
|
||||
mod edges;
|
||||
mod pixelate;
|
||||
mod sharpen;
|
||||
@@ -22,13 +26,26 @@ use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
|
||||
pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
|
||||
pub use distort::{GLASS_TEXTURES, GlassTexture};
|
||||
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 16] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 27] = [
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
"brushStrokes.accentedEdges",
|
||||
"brushStrokes.angledStrokes",
|
||||
"brushStrokes.crosshatch",
|
||||
"brushStrokes.darkStrokes",
|
||||
"brushStrokes.inkOutlines",
|
||||
"brushStrokes.spatter",
|
||||
"brushStrokes.sprayedStrokes",
|
||||
"brushStrokes.sumiE",
|
||||
"distort.diffuseGlow",
|
||||
"distort.glass",
|
||||
"distort.oceanRipple",
|
||||
"pixelate.colorHalftone",
|
||||
"pixelate.crystallize",
|
||||
"pixelate.mezzotint",
|
||||
@@ -56,6 +73,42 @@ pub enum PixelFx {
|
||||
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
|
||||
/// `samples` × `samples` neighbours at most.
|
||||
SmartBlur { radius: f64, threshold: f64, samples: u32 },
|
||||
/// Brush Strokes › Accented Edges: the edges, about `width` (1..14) wide, accented towards
|
||||
/// white chalk or black ink as `brightness` (0..50) says; `smoothness` (1..15) softens them.
|
||||
AccentedEdges { width: f64, brightness: f64, smoothness: f64 },
|
||||
/// Brush Strokes › Angled Strokes: diagonal strokes `length` long, the light areas' and the
|
||||
/// dark areas' going opposite ways, split as `balance` (0..100) says, as crisp as `sharpness`
|
||||
/// (0..10).
|
||||
AngledStrokes { balance: f64, length: f64, sharpness: f64 },
|
||||
/// Brush Strokes › Crosshatch: pencil hatching `length` long along both diagonals, as crisp
|
||||
/// as `sharpness` (0..20), in `strength` (1..3) passes.
|
||||
Crosshatch { length: f64, sharpness: f64, strength: u32 },
|
||||
/// Brush Strokes › Dark Strokes: short strokes towards black in the dark areas, long ones
|
||||
/// towards white in the light areas, split by `balance` (0..10), as dark as `black` (0..10)
|
||||
/// and as light as `white` (0..10).
|
||||
DarkStrokes { balance: f64, black: f64, white: f64 },
|
||||
/// Brush Strokes › Ink Outlines: pen-and-ink outlines and strokes `length` long, the shadows
|
||||
/// darkened by `dark` (0..50) and the highlights lightened by `light` (0..50).
|
||||
InkOutlines { length: f64, dark: f64, light: f64 },
|
||||
/// Brush Strokes › Spatter: a spatter airbrush spraying within `radius` (0..25), its grains as
|
||||
/// smooth as `smoothness` (1..15).
|
||||
Spatter { radius: f64, smoothness: f64 },
|
||||
/// Brush Strokes › Sprayed Strokes: sprayed strokes `length` long along `direction`, scattered
|
||||
/// as `radius` (0..25) says.
|
||||
SprayedStrokes { length: f64, radius: f64, direction: StrokeDirection },
|
||||
/// Brush Strokes › Sumi-e: wet black brush strokes `width` (3..15) across on rice paper, as
|
||||
/// loaded as `pressure` (0..15) says, with `contrast` (0..40).
|
||||
SumiE { width: f64, pressure: f64, contrast: f64 },
|
||||
/// Distort › Diffuse Glow: the highlights glow white as strongly as `glow` (0..20), from the
|
||||
/// brightness `clear` (0..20) leaves clear, under white grain as dense as `graininess` (0..10).
|
||||
DiffuseGlow { graininess: f64, glow: f64, clear: f64 },
|
||||
/// Distort › Glass: the object seen through a `texture` surface scaled by `scaling` (0.5..2),
|
||||
/// bent by `distortion` (0..20), smoothed by `smoothness` (1..15), its heights turned over when
|
||||
/// `invert`.
|
||||
Glass { distortion: f64, smoothness: f64, texture: GlassTexture, scaling: f64, invert: bool },
|
||||
/// Distort › Ocean Ripple: the object under ripples of `size` (1..15) shifting it as much as
|
||||
/// `magnitude` (0..20) says.
|
||||
OceanRipple { size: f64, magnitude: f64 },
|
||||
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
|
||||
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
|
||||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
@@ -149,6 +202,59 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0),
|
||||
samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7),
|
||||
},
|
||||
"brushStrokes.accentedEdges" => PixelFx::AccentedEdges {
|
||||
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
|
||||
brightness: num(p, "edgeBrightness", 38.0).clamp(0.0, 50.0),
|
||||
smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0),
|
||||
},
|
||||
"brushStrokes.angledStrokes" => PixelFx::AngledStrokes {
|
||||
balance: num(p, "directionBalance", 50.0).clamp(0.0, 100.0),
|
||||
length: num(p, "strokeLength", 15.0).clamp(3.0, 50.0),
|
||||
sharpness: num(p, "sharpness", 3.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"brushStrokes.crosshatch" => PixelFx::Crosshatch {
|
||||
length: num(p, "strokeLength", 9.0).clamp(3.0, 50.0),
|
||||
sharpness: num(p, "sharpness", 6.0).clamp(0.0, 20.0),
|
||||
strength: num(p, "strength", 1.0).clamp(1.0, 3.0).round() as u32,
|
||||
},
|
||||
"brushStrokes.darkStrokes" => PixelFx::DarkStrokes {
|
||||
balance: num(p, "balance", 5.0).clamp(0.0, 10.0),
|
||||
black: num(p, "blackIntensity", 6.0).clamp(0.0, 10.0),
|
||||
white: num(p, "whiteIntensity", 2.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"brushStrokes.inkOutlines" => PixelFx::InkOutlines {
|
||||
length: num(p, "strokeLength", 4.0).clamp(1.0, 50.0),
|
||||
dark: num(p, "darkIntensity", 20.0).clamp(0.0, 50.0),
|
||||
light: num(p, "lightIntensity", 10.0).clamp(0.0, 50.0),
|
||||
},
|
||||
"brushStrokes.spatter" => {
|
||||
PixelFx::Spatter { radius: num(p, "sprayRadius", 10.0).clamp(0.0, 25.0), smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0) }
|
||||
}
|
||||
"brushStrokes.sprayedStrokes" => PixelFx::SprayedStrokes {
|
||||
length: num(p, "strokeLength", 12.0).clamp(0.0, 20.0),
|
||||
radius: num(p, "sprayRadius", 7.0).clamp(0.0, 25.0),
|
||||
direction: StrokeDirection::parse(text(p, "strokeDirection", "rightDiagonal")),
|
||||
},
|
||||
"brushStrokes.sumiE" => PixelFx::SumiE {
|
||||
width: num(p, "strokeWidth", 10.0).clamp(3.0, 15.0),
|
||||
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 15.0),
|
||||
contrast: num(p, "contrast", 16.0).clamp(0.0, 40.0),
|
||||
},
|
||||
"distort.diffuseGlow" => PixelFx::DiffuseGlow {
|
||||
graininess: num(p, "graininess", 6.0).clamp(0.0, 10.0),
|
||||
glow: num(p, "glowAmount", 10.0).clamp(0.0, 20.0),
|
||||
clear: num(p, "clearAmount", 15.0).clamp(0.0, 20.0),
|
||||
},
|
||||
"distort.glass" => PixelFx::Glass {
|
||||
distortion: num(p, "distortion", 5.0).clamp(0.0, 20.0),
|
||||
smoothness: num(p, "smoothness", 3.0).clamp(1.0, 15.0),
|
||||
texture: GlassTexture::parse(text(p, "texture", "frosted")),
|
||||
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
|
||||
invert: flag(p, "invert", false),
|
||||
},
|
||||
"distort.oceanRipple" => {
|
||||
PixelFx::OceanRipple { size: num(p, "rippleSize", 9.0).clamp(1.0, 15.0), magnitude: num(p, "rippleMagnitude", 9.0).clamp(0.0, 20.0) }
|
||||
}
|
||||
"sharpen.unsharpMask" => PixelFx::UnsharpMask {
|
||||
amount: num(p, "amount", 50.0).clamp(1.0, 500.0) / 100.0,
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
@@ -217,6 +323,19 @@ impl PixelFx {
|
||||
PixelFx::RadialBlur { zoom: false, .. } => (far - near).max(0.0),
|
||||
PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0),
|
||||
PixelFx::SmartBlur { radius, .. } => radius,
|
||||
PixelFx::AccentedEdges { .. } => 0.0,
|
||||
// Strokes reach out of the object as far as they reach into it.
|
||||
PixelFx::AngledStrokes { length, .. } | PixelFx::InkOutlines { length, .. } => brushstrokes::stroke_reach(length),
|
||||
PixelFx::Crosshatch { length, .. } => brushstrokes::stroke_reach(length) + brushstrokes::rough(length),
|
||||
PixelFx::DarkStrokes { .. } => brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH),
|
||||
PixelFx::Spatter { radius, .. } => brushstrokes::spatter_shift(radius),
|
||||
PixelFx::SprayedStrokes { length, radius, .. } => brushstrokes::stroke_reach(length) + brushstrokes::spray_shift(radius),
|
||||
// Sumi-e keeps the object's shape.
|
||||
PixelFx::SumiE { .. } => 0.0,
|
||||
PixelFx::DiffuseGlow { .. } => 0.0,
|
||||
// Content shifted outwards reaches past the bounds by as much as the shift.
|
||||
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
|
||||
PixelFx::UnsharpMask { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
PixelFx::ColorHalftone { .. }
|
||||
@@ -240,6 +359,20 @@ impl PixelFx {
|
||||
match *self {
|
||||
PixelFx::RadialBlur { .. } => None,
|
||||
PixelFx::SmartBlur { radius, .. } => Some(radius),
|
||||
PixelFx::AccentedEdges { width, smoothness, .. } => Some(brushstrokes::accent_reach(width, smoothness)),
|
||||
// The strokes, and the softened tones that choose them.
|
||||
PixelFx::AngledStrokes { length, .. } => Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH)),
|
||||
PixelFx::Crosshatch { length, .. } => {
|
||||
Some((brushstrokes::stroke_reach(length) + brushstrokes::rough(length)).max(brushstrokes::TONE_REACH))
|
||||
}
|
||||
PixelFx::DarkStrokes { .. } => Some(brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH).max(brushstrokes::TONE_REACH)),
|
||||
PixelFx::InkOutlines { length, .. } => Some(brushstrokes::ink_reach(length)),
|
||||
PixelFx::Spatter { radius, .. } => Some(brushstrokes::spatter_shift(radius)),
|
||||
PixelFx::SprayedStrokes { length, radius, .. } => Some(brushstrokes::stroke_reach(length) + brushstrokes::spray_shift(radius)),
|
||||
PixelFx::SumiE { width, .. } => Some(brushstrokes::sumi_reach(width)),
|
||||
PixelFx::DiffuseGlow { .. } => Some(3.0 * distort::GLOW_SPREAD),
|
||||
PixelFx::Glass { distortion, .. } => Some(distort::glass_shift(distortion)),
|
||||
PixelFx::OceanRipple { size, magnitude } => Some(distort::ripple_shift(size, magnitude)),
|
||||
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)),
|
||||
// The cells around the pixel's, and the cells around theirs.
|
||||
@@ -268,6 +401,19 @@ impl PixelFx {
|
||||
match *self {
|
||||
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
|
||||
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
|
||||
PixelFx::AccentedEdges { width, brightness, smoothness } => brushstrokes::accented_edges(px, w, h, space, width, brightness, smoothness),
|
||||
PixelFx::AngledStrokes { balance, length, sharpness } => brushstrokes::angled_strokes(px, w, h, space, balance, length, sharpness),
|
||||
PixelFx::Crosshatch { length, sharpness, strength } => brushstrokes::crosshatch(px, w, h, space, length, sharpness, strength),
|
||||
PixelFx::DarkStrokes { balance, black, white } => brushstrokes::dark_strokes(px, w, h, space, balance, black, white),
|
||||
PixelFx::InkOutlines { length, dark, light } => brushstrokes::ink_outlines(px, w, h, space, length, dark, light),
|
||||
PixelFx::Spatter { radius, smoothness } => brushstrokes::spatter(px, w, h, space, radius, smoothness),
|
||||
PixelFx::SprayedStrokes { length, radius, direction } => brushstrokes::sprayed_strokes(px, w, h, space, length, radius, direction),
|
||||
PixelFx::SumiE { width, pressure, contrast } => brushstrokes::sumi_e(px, w, h, space, width, pressure, contrast),
|
||||
PixelFx::DiffuseGlow { graininess, glow, clear } => distort::diffuse_glow(px, w, h, space, graininess, glow, clear),
|
||||
PixelFx::Glass { distortion, smoothness, texture, scaling, invert } => {
|
||||
distort::glass(px, w, h, space, distortion, smoothness, texture, scaling, invert)
|
||||
}
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
|
||||
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
|
||||
|
||||
@@ -276,6 +276,14 @@ fn pixel_timings() {
|
||||
("blur.smart", json!({"quality": "low"})),
|
||||
("blur.smart", json!({"quality": "high", "radius": 30})),
|
||||
("sharpen.unsharpMask", json!({"radius": 20})),
|
||||
("brushStrokes.accentedEdges", json!({"edgeWidth": 14})),
|
||||
("brushStrokes.angledStrokes", json!({"strokeLength": 50})),
|
||||
("brushStrokes.crosshatch", json!({"strokeLength": 50, "strength": 3})),
|
||||
("brushStrokes.darkStrokes", json!({})),
|
||||
("brushStrokes.inkOutlines", json!({"strokeLength": 50})),
|
||||
("brushStrokes.spatter", json!({"sprayRadius": 25})),
|
||||
("brushStrokes.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25})),
|
||||
("brushStrokes.sumiE", json!({"strokeWidth": 15})),
|
||||
("pixelate.colorHalftone", json!({"maxRadius": 4})),
|
||||
("pixelate.crystallize", json!({"cellSize": 3})),
|
||||
("pixelate.crystallize", json!({"cellSize": 300})),
|
||||
@@ -826,3 +834,478 @@ fn texture_patterns_follow_the_object_at_any_resolution() {
|
||||
assert_eq!(shifted, one, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("distort.diffuseGlow", json!({})), PixelFx::DiffuseGlow { graininess: 6.0, glow: 10.0, clear: 15.0 });
|
||||
assert_eq!(
|
||||
fx("distort.diffuseGlow", json!({"graininess": 1e9, "glowAmount": -3, "clearAmount": "x"})),
|
||||
PixelFx::DiffuseGlow { graininess: 10.0, glow: 0.0, clear: 15.0 }
|
||||
);
|
||||
assert_eq!(
|
||||
fx("distort.glass", json!({})),
|
||||
PixelFx::Glass { distortion: 5.0, smoothness: 3.0, texture: GlassTexture::Frosted, scaling: 1.0, invert: false }
|
||||
);
|
||||
assert_eq!(
|
||||
fx("distort.glass", json!({"distortion": 1e308, "smoothness": 0, "texture": "TINYLENS", "scaling": 1, "invert": true})),
|
||||
PixelFx::Glass { distortion: 20.0, smoothness: 1.0, texture: GlassTexture::TinyLens, scaling: 0.5, invert: true }
|
||||
);
|
||||
assert_eq!(fx("distort.glass", json!({"texture": "bubbles"})), fx("distort.glass", json!({})));
|
||||
assert_eq!(fx("distort.oceanRipple", json!({})), PixelFx::OceanRipple { size: 9.0, magnitude: 9.0 });
|
||||
assert_eq!(fx("distort.oceanRipple", json!({"rippleSize": 99, "rippleMagnitude": -1})), PixelFx::OceanRipple { size: 15.0, magnitude: 0.0 });
|
||||
// Shifted content reaches past the bounds by the longest shift, and comes from as far.
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
let glass = fx("distort.glass", json!({"distortion": 20}));
|
||||
assert_eq!((glass.outset(b), glass.reach()), (10.0, Some(10.0)));
|
||||
let ripple = fx("distort.oceanRipple", json!({}));
|
||||
assert!((ripple.outset(b) - 0.03 * 9.0 * 11.0).abs() < 1e-9 && ripple.reach() == Some(ripple.outset(b)));
|
||||
assert_eq!(fx("distort.diffuseGlow", json!({})).outset(b), 0.0);
|
||||
}
|
||||
|
||||
/// Vertical stripes 5 points wide (out of step with every glass surface), alternately light and
|
||||
/// dark, over the whole raster.
|
||||
fn stripes(w: usize, h: usize) -> Vec<u8> {
|
||||
image(w, h, |x, _| if (x / 5) % 2 == 0 { [230, 200, 60, 255] } else { [20, 40, 120, 255] })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_bends_the_image_by_its_texture() {
|
||||
let (w, h) = (48, 40);
|
||||
let src = stripes(w, h);
|
||||
let run = |p: serde_json::Value| {
|
||||
let mut d = src.clone();
|
||||
fx("distort.glass", p).apply(&mut d, w, h, &space(w, h));
|
||||
d
|
||||
};
|
||||
// No distortion, no change.
|
||||
assert_eq!(run(json!({"distortion": 0})), src);
|
||||
let mut seen = vec![];
|
||||
for (_, texture) in GLASS_TEXTURES {
|
||||
let bent = run(json!({"texture": texture, "distortion": 12}));
|
||||
assert_ne!(bent, src, "{texture}");
|
||||
assert!(!seen.contains(&bent), "{texture} differs from the other textures");
|
||||
assert_ne!(run(json!({"texture": texture, "distortion": 12, "invert": true})), bent, "{texture} inverted");
|
||||
// A colour moves; none is invented: every pixel blends two neighbouring stripes at most
|
||||
// (those within a shift of the raster's edges also take in the transparency beyond it).
|
||||
for (x, y) in (6..w - 6).flat_map(|x| (6..h - 6).map(move |y| (x, y))) {
|
||||
let p = at(&bent, w, x, y);
|
||||
assert!(p[3] == 255 && p[0] >= 20 && p[0] <= 230, "{texture} at ({x}, {y}): {p:?}");
|
||||
}
|
||||
seen.push(bent);
|
||||
}
|
||||
// Smoother glass bends the stripes differently (more gently).
|
||||
assert_ne!(run(json!({"distortion": 12, "smoothness": 15})), run(json!({"distortion": 12, "smoothness": 1})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocean_ripple_moves_content_no_farther_than_its_magnitude() {
|
||||
let (w, h) = (64, 32);
|
||||
// The left half white, the right half transparent.
|
||||
let src = image(w, h, |x, _| if x < 32 { grey(255) } else { [0; 4] });
|
||||
let mut d = src.clone();
|
||||
fx("distort.oceanRipple", json!({"rippleMagnitude": 0})).apply(&mut d, w, h, &space(w, h));
|
||||
assert_eq!(d, src, "no magnitude, no ripples");
|
||||
let f = fx("distort.oceanRipple", json!({"rippleSize": 6, "rippleMagnitude": 20}));
|
||||
let most = f.reach().unwrap();
|
||||
let mut d = src.clone();
|
||||
f.apply(&mut d, w, h, &space(w, h));
|
||||
assert_ne!(d, src);
|
||||
// Away from the white's edge, and from the raster's (beyond which is transparency), nothing
|
||||
// changes.
|
||||
let inside = |v: usize, n: usize| (v as f64 + 0.5).min(n as f64 - v as f64 - 0.5) > most + 1.0;
|
||||
for y in (0..h).filter(|y| inside(*y, h)) {
|
||||
for x in (0..w).filter(|x| inside(*x, w)) {
|
||||
let to_edge = (x as f64 + 0.5 - 32.0).abs();
|
||||
if to_edge > most + 1.0 {
|
||||
assert_eq!(at(&d, w, x, y), at(&src, w, x, y), "({x}, {y}) is {to_edge} from the edge, past {most}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// The edge wanders: some rows reach farther right than others.
|
||||
let ends: Vec<usize> = (0..h).map(|y| (0..w).filter(|x| at(&d, w, *x, y)[3] > 127).count()).collect();
|
||||
assert!(ends.iter().min() != ends.iter().max(), "{ends:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diffuse_glow_whitens_the_highlights_and_sprinkles_grain() {
|
||||
let (w, h) = (48, 48);
|
||||
// Dark on the left, light on the right, a transparent margin round them.
|
||||
let src = image(w, h, |x, y| {
|
||||
if !(4..44).contains(&x) || !(4..44).contains(&y) {
|
||||
[0; 4]
|
||||
} else if x < 24 {
|
||||
[30, 30, 60, 255]
|
||||
} else {
|
||||
[200, 190, 170, 255]
|
||||
}
|
||||
});
|
||||
let run = |p: serde_json::Value| {
|
||||
let mut d = src.clone();
|
||||
fx("distort.diffuseGlow", p).apply(&mut d, w, h, &space(w, h));
|
||||
d
|
||||
};
|
||||
let clean = run(json!({"graininess": 0}));
|
||||
// Transparency stays; the shadows stay clear; the highlights glow.
|
||||
for (a, b) in clean.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(a[3], b[3]);
|
||||
}
|
||||
assert_eq!(at(&clean, w, 10, 24), at(&src, w, 10, 24));
|
||||
let (lit, before) = (at(&clean, w, 36, 24), at(&src, w, 36, 24));
|
||||
assert!((0..3).all(|c| lit[c] > before[c]), "{lit:?} brighter than {before:?}");
|
||||
// More Clear Amount leaves more of the image clear of glow.
|
||||
assert!(at(&run(json!({"graininess": 0, "clearAmount": 20})), w, 36, 24)[0] < lit[0]);
|
||||
assert_eq!(run(json!({"graininess": 0, "glowAmount": 0})), src);
|
||||
// Grain: white specks, in the shadows too.
|
||||
let grainy = run(json!({"graininess": 10}));
|
||||
let specks = (4..24).flat_map(|x| (4..44).map(move |y| (x, y))).filter(|(x, y)| at(&grainy, w, *x, *y)[0] > 100).count();
|
||||
assert!(specks > 10, "{specks} white specks in the shadows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_patterns_follow_the_object_at_any_resolution() {
|
||||
let (w, h) = (48, 48);
|
||||
let art = |x: f64, y: f64| -> [u8; 4] {
|
||||
if (8.0..40.0).contains(&x) && (8.0..40.0).contains(&y) { [(x * 5.0) as u8, (y * 5.0) as u8, 120, 255] } else { [0; 4] }
|
||||
};
|
||||
for (id, p) in [
|
||||
("distort.diffuseGlow", json!({"clearAmount": 5})),
|
||||
("distort.glass", json!({"texture": "blocks", "distortion": 8})),
|
||||
("distort.oceanRipple", json!({"rippleMagnitude": 12})),
|
||||
] {
|
||||
let f = fx(id, p);
|
||||
let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
|
||||
f.apply(&mut one, w, h, &space(w, h));
|
||||
let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
|
||||
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
|
||||
f.apply(&mut two, 2 * w, 2 * h, &fine);
|
||||
let down = image(w, h, |x, y| {
|
||||
let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy));
|
||||
std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::<u32>() / 4) as u8)
|
||||
});
|
||||
let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::<f64>() / one.len() as f64;
|
||||
assert!(diff < 12.0, "{id}: {diff}");
|
||||
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(31.0, 27.0), ..space(w, h) };
|
||||
let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
|
||||
f.apply(&mut shifted, w, h, &moved);
|
||||
assert_eq!(shifted, one, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Brush Strokes effect ids.
|
||||
fn brush_strokes() -> impl Iterator<Item = &'static str> {
|
||||
PIXEL_EFFECTS.into_iter().filter(|id| id.starts_with("brushStrokes."))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_strokes_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("brushStrokes.accentedEdges", json!({})), PixelFx::AccentedEdges { width: 2.0, brightness: 38.0, smoothness: 5.0 });
|
||||
assert_eq!(
|
||||
fx("brushStrokes.accentedEdges", json!({"edgeWidth": 99, "edgeBrightness": -1, "smoothness": "x"})),
|
||||
PixelFx::AccentedEdges { width: 14.0, brightness: 0.0, smoothness: 5.0 }
|
||||
);
|
||||
assert_eq!(fx("brushStrokes.angledStrokes", json!({})), PixelFx::AngledStrokes { balance: 50.0, length: 15.0, sharpness: 3.0 });
|
||||
assert_eq!(
|
||||
fx("brushStrokes.angledStrokes", json!({"directionBalance": 1e308, "strokeLength": 0, "sharpness": 11})),
|
||||
PixelFx::AngledStrokes { balance: 100.0, length: 3.0, sharpness: 10.0 }
|
||||
);
|
||||
assert_eq!(fx("brushStrokes.crosshatch", json!({})), PixelFx::Crosshatch { length: 9.0, sharpness: 6.0, strength: 1 });
|
||||
assert_eq!(
|
||||
fx("brushStrokes.crosshatch", json!({"strokeLength": 51, "sharpness": -1, "strength": 2.6})),
|
||||
PixelFx::Crosshatch { length: 50.0, sharpness: 0.0, strength: 3 }
|
||||
);
|
||||
assert_eq!(fx("brushStrokes.crosshatch", json!({"strength": -7})), PixelFx::Crosshatch { length: 9.0, sharpness: 6.0, strength: 1 });
|
||||
assert_eq!(fx("brushStrokes.darkStrokes", json!({})), PixelFx::DarkStrokes { balance: 5.0, black: 6.0, white: 2.0 });
|
||||
assert_eq!(
|
||||
fx("brushStrokes.darkStrokes", json!({"balance": 11, "blackIntensity": -1, "whiteIntensity": 1e9})),
|
||||
PixelFx::DarkStrokes { balance: 10.0, black: 0.0, white: 10.0 }
|
||||
);
|
||||
assert_eq!(fx("brushStrokes.inkOutlines", json!({})), PixelFx::InkOutlines { length: 4.0, dark: 20.0, light: 10.0 });
|
||||
assert_eq!(
|
||||
fx("brushStrokes.inkOutlines", json!({"strokeLength": 0, "darkIntensity": 51, "lightIntensity": -5})),
|
||||
PixelFx::InkOutlines { length: 1.0, dark: 50.0, light: 0.0 }
|
||||
);
|
||||
assert_eq!(fx("brushStrokes.spatter", json!({})), PixelFx::Spatter { radius: 10.0, smoothness: 5.0 });
|
||||
assert_eq!(fx("brushStrokes.spatter", json!({"sprayRadius": 26, "smoothness": 0})), PixelFx::Spatter { radius: 25.0, smoothness: 1.0 });
|
||||
assert_eq!(
|
||||
fx("brushStrokes.sprayedStrokes", json!({})),
|
||||
PixelFx::SprayedStrokes { length: 12.0, radius: 7.0, direction: StrokeDirection::RightDiagonal }
|
||||
);
|
||||
assert_eq!(
|
||||
fx("brushStrokes.sprayedStrokes", json!({"strokeLength": -1, "sprayRadius": 1e308, "strokeDirection": "VERTICAL"})),
|
||||
PixelFx::SprayedStrokes { length: 0.0, radius: 25.0, direction: StrokeDirection::Vertical }
|
||||
);
|
||||
assert_eq!(fx("brushStrokes.sprayedStrokes", json!({"strokeDirection": "diagonal"})), fx("brushStrokes.sprayedStrokes", json!({})));
|
||||
assert_eq!(fx("brushStrokes.sumiE", json!({})), PixelFx::SumiE { width: 10.0, pressure: 2.0, contrast: 16.0 });
|
||||
assert_eq!(
|
||||
fx("brushStrokes.sumiE", json!({"strokeWidth": 2, "strokePressure": 16, "contrast": -1})),
|
||||
PixelFx::SumiE { width: 3.0, pressure: 15.0, contrast: 0.0 }
|
||||
);
|
||||
// Outsets: the strokes and the shifts reach out of the object as far as into it; Accented
|
||||
// Edges and Sumi-e keep the object's shape.
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
let reach = brushstrokes::stroke_reach;
|
||||
assert!((reach(20.0) - (0.75 * 20.0 + 1.5 / 15.0 * 20.0 + 1.0)).abs() < 1e-9);
|
||||
assert_eq!(fx("brushStrokes.accentedEdges", json!({})).outset(b), 0.0);
|
||||
assert_eq!(fx("brushStrokes.accentedEdges", json!({})).reach(), Some(3.0 * (0.35 * 2.0 + 0.5) + 1.0));
|
||||
assert_eq!(fx("brushStrokes.angledStrokes", json!({})).outset(b), reach(15.0));
|
||||
assert_eq!(fx("brushStrokes.crosshatch", json!({})).outset(b), reach(9.0) + 0.9);
|
||||
assert_eq!(fx("brushStrokes.darkStrokes", json!({})).outset(b), reach(9.0));
|
||||
assert_eq!(fx("brushStrokes.inkOutlines", json!({"strokeLength": 50})).outset(b), reach(50.0));
|
||||
let spatter = fx("brushStrokes.spatter", json!({}));
|
||||
assert_eq!((spatter.outset(b), spatter.reach()), (4.0, Some(4.0)));
|
||||
assert_eq!(fx("brushStrokes.spatter", json!({"sprayRadius": 0})).outset(b), 0.0);
|
||||
assert_eq!(fx("brushStrokes.sprayedStrokes", json!({})).outset(b), reach(12.0) + 0.3 * 7.0);
|
||||
assert_eq!(fx("brushStrokes.sumiE", json!({})).outset(b), 0.0);
|
||||
for id in brush_strokes() {
|
||||
let f = fx(id, json!({}));
|
||||
assert!(f.reach().is_some_and(|r| r >= f.outset(b)), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A light square in a darker ground with a ramp across it, over the whole raster: edges,
|
||||
/// shadows and highlights for the strokes to work on.
|
||||
fn scene(w: usize, h: usize) -> Vec<u8> {
|
||||
image(w, h, |x, y| if (12..36).contains(&x) && (12..36).contains(&y) { [235, 220, 190, 255] } else { [(30 + x) as u8, 40, 90, 255] })
|
||||
}
|
||||
|
||||
/// `src` (`w` × `h`) through effect `id` with parameters `p`.
|
||||
fn run_fx(src: &[u8], w: usize, h: usize, id: &str, p: serde_json::Value) -> Vec<u8> {
|
||||
let mut d = src.to_vec();
|
||||
fx(id, p).apply(&mut d, w, h, &space(w, h));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_strokes_each_repaint_the_art_their_own_way() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = scene(w, h);
|
||||
let mut seen: Vec<(&str, Vec<u8>)> = vec![];
|
||||
for id in brush_strokes() {
|
||||
let d = run_fx(&src, w, h, id, json!({}));
|
||||
assert_ne!(d, src, "{id} changes the art");
|
||||
for (other, o) in &seen {
|
||||
assert_ne!(&d, o, "{id} differs from {other}");
|
||||
}
|
||||
seen.push((id, d));
|
||||
}
|
||||
assert_eq!(seen.len(), 8);
|
||||
// Each stroke direction paints its own way.
|
||||
let mut ways: Vec<Vec<u8>> = vec![];
|
||||
for (_, way) in STROKE_DIRECTIONS {
|
||||
let d = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"strokeDirection": way}));
|
||||
assert!(!ways.contains(&d), "{way} differs from the other directions");
|
||||
ways.push(d);
|
||||
}
|
||||
// Every option shows.
|
||||
for (id, a, b) in [
|
||||
("brushStrokes.accentedEdges", json!({"edgeWidth": 1}), json!({"edgeWidth": 14})),
|
||||
("brushStrokes.accentedEdges", json!({"smoothness": 1}), json!({"smoothness": 15})),
|
||||
("brushStrokes.angledStrokes", json!({"directionBalance": 0}), json!({"directionBalance": 100})),
|
||||
("brushStrokes.angledStrokes", json!({"strokeLength": 3}), json!({"strokeLength": 50})),
|
||||
("brushStrokes.angledStrokes", json!({"sharpness": 0}), json!({"sharpness": 10})),
|
||||
("brushStrokes.crosshatch", json!({"strokeLength": 3}), json!({"strokeLength": 50})),
|
||||
("brushStrokes.crosshatch", json!({"sharpness": 0}), json!({"sharpness": 20})),
|
||||
("brushStrokes.crosshatch", json!({"strength": 1}), json!({"strength": 3})),
|
||||
("brushStrokes.darkStrokes", json!({"balance": 0}), json!({"balance": 10})),
|
||||
("brushStrokes.darkStrokes", json!({"blackIntensity": 0}), json!({"blackIntensity": 10})),
|
||||
("brushStrokes.darkStrokes", json!({"whiteIntensity": 0}), json!({"whiteIntensity": 10})),
|
||||
("brushStrokes.inkOutlines", json!({"strokeLength": 1}), json!({"strokeLength": 50})),
|
||||
("brushStrokes.inkOutlines", json!({"darkIntensity": 0}), json!({"darkIntensity": 50})),
|
||||
("brushStrokes.inkOutlines", json!({"lightIntensity": 0}), json!({"lightIntensity": 50})),
|
||||
("brushStrokes.spatter", json!({"sprayRadius": 5}), json!({"sprayRadius": 25})),
|
||||
("brushStrokes.spatter", json!({"smoothness": 1}), json!({"smoothness": 15})),
|
||||
("brushStrokes.sprayedStrokes", json!({"strokeLength": 0}), json!({"strokeLength": 20})),
|
||||
("brushStrokes.sprayedStrokes", json!({"sprayRadius": 0}), json!({"sprayRadius": 25})),
|
||||
("brushStrokes.sumiE", json!({"strokeWidth": 3}), json!({"strokeWidth": 15})),
|
||||
("brushStrokes.sumiE", json!({"strokePressure": 0}), json!({"strokePressure": 15})),
|
||||
("brushStrokes.sumiE", json!({"contrast": 0}), json!({"contrast": 40})),
|
||||
] {
|
||||
assert_ne!(run_fx(&src, w, h, id, a.clone()), run_fx(&src, w, h, id, b.clone()), "{id}: {a} and {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The mean of the colour channels over columns `xs` and rows `ys` (0..255).
|
||||
fn mean_tone(d: &[u8], w: usize, xs: std::ops::Range<usize>, ys: std::ops::Range<usize>) -> f64 {
|
||||
let px: Vec<[u8; 4]> = ys.flat_map(|y| xs.clone().map(move |x| (x, y))).map(|(x, y)| at(d, w, x, y)).collect();
|
||||
px.iter().map(|p| f64::from(p[0]) + f64::from(p[1]) + f64::from(p[2])).sum::<f64>() / (3.0 * px.len().max(1) as f64)
|
||||
}
|
||||
|
||||
/// Dark on the left, light on the right, opaque over the whole raster.
|
||||
fn halves(w: usize, h: usize) -> Vec<u8> {
|
||||
image(w, h, |x, _| if x < w / 2 { [50, 60, 100, 255] } else { [210, 200, 180, 255] })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accented_edges_draw_chalk_or_ink_on_the_edges_only() {
|
||||
let (w, h) = (48, 48);
|
||||
// The halves inside a transparent margin.
|
||||
let both = halves(w, h);
|
||||
let src = image(w, h, |x, y| if (4..44).contains(&x) && (4..44).contains(&y) { at(&both, w, x, y) } else { [0; 4] });
|
||||
let chalk = run_fx(&src, w, h, "brushStrokes.accentedEdges", json!({"edgeBrightness": 50}));
|
||||
let ink = run_fx(&src, w, h, "brushStrokes.accentedEdges", json!({"edgeBrightness": 0}));
|
||||
let sum = |p: [u8; 4]| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]);
|
||||
// Along the edge between the halves: white chalk on the dark side, black ink on the light.
|
||||
assert!(sum(at(&chalk, w, 23, 24)) > sum(at(&src, w, 23, 24)) + 150, "{:?}", at(&chalk, w, 23, 24));
|
||||
assert!(sum(at(&ink, w, 24, 24)) + 150 < sum(at(&src, w, 24, 24)), "{:?}", at(&ink, w, 24, 24));
|
||||
// Away from the edges nothing changes, and the object keeps its shape.
|
||||
for (x, y) in [(13, 24), (34, 24), (13, 13)] {
|
||||
assert_eq!(at(&chalk, w, x, y), at(&src, w, x, y), "({x}, {y})");
|
||||
}
|
||||
for (a, b) in chalk.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(a[3], b[3]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_and_ink_strokes_darken_the_shadows_and_sumi_e_inks_them_black() {
|
||||
let (w, h) = (64, 48);
|
||||
let src = halves(w, h);
|
||||
let (shadow, light) = (8..24, 40..56);
|
||||
let tone = |d: &[u8], xs: std::ops::Range<usize>| mean_tone(d, w, xs, 8..40);
|
||||
let dark = run_fx(&src, w, h, "brushStrokes.darkStrokes", json!({}));
|
||||
assert!(tone(&dark, shadow.clone()) < tone(&src, shadow.clone()) - 20.0, "dark strokes in the shadows");
|
||||
assert!(tone(&dark, light.clone()) > tone(&src, light.clone()) + 5.0, "white strokes in the light");
|
||||
let ink = run_fx(&src, w, h, "brushStrokes.inkOutlines", json!({}));
|
||||
assert!(tone(&ink, shadow.clone()) < tone(&src, shadow.clone()) - 10.0, "ink in the shadows");
|
||||
// The outline along the edge is darker than the light side around it.
|
||||
assert!(tone(&ink, 32..34) + 40.0 < tone(&ink, light.clone()), "an ink outline along the edge");
|
||||
let sumi = run_fx(&src, w, h, "brushStrokes.sumiE", json!({}));
|
||||
assert!(tone(&sumi, shadow.clone()) < 0.4 * tone(&src, shadow.clone()), "rich blacks: {}", tone(&sumi, shadow.clone()));
|
||||
assert!(tone(&sumi, 50..56) > 150.0, "the light stays light: {}", tone(&sumi, 50..56));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sumi_e_keeps_the_shape_and_the_hues() {
|
||||
let (w, h) = (48, 48);
|
||||
// A strong yellow square and a dark blue one in a transparent margin.
|
||||
let src = image(w, h, |x, y| match (x, y) {
|
||||
(8..24, 8..40) => [230, 200, 40, 255],
|
||||
(24..40, 8..40) => [30, 40, 120, 255],
|
||||
_ => [0; 4],
|
||||
});
|
||||
let d = run_fx(&src, w, h, "brushStrokes.sumiE", json!({"contrast": 40, "strokePressure": 15}));
|
||||
// Transparency stays transparent and the coverage stays as it was: no fringe.
|
||||
for (a, b) in d.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(a[3], b[3]);
|
||||
if b[3] == 0 {
|
||||
assert_eq!(*a, [0; 4]);
|
||||
}
|
||||
}
|
||||
// The yellow stays yellow, no more saturated than it was.
|
||||
let sat = |p: [u8; 4]| {
|
||||
let (hi, lo) = (p[..3].iter().max().copied().unwrap(), p[..3].iter().min().copied().unwrap());
|
||||
f64::from(hi - lo) / f64::from(hi.max(1))
|
||||
};
|
||||
let y = at(&d, w, 12, 24);
|
||||
assert!(y[0] > y[2] + 80 && y[1] > y[2] + 80, "{y:?}");
|
||||
assert!(sat(y) <= sat([230, 200, 40, 255]) + 0.02, "{y:?}");
|
||||
// The dark blue is inked darker, still blue.
|
||||
let b = at(&d, w, 34, 24);
|
||||
assert!(b[2] < 120 && b[2] >= b[0] && b[2] >= b[1], "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crosshatch_hatches_flat_colour_and_keeps_its_tone() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |_, _| grey(128));
|
||||
let spread = |d: &[u8]| {
|
||||
let m = mean_tone(d, w, 8..40, 8..40);
|
||||
let var = (8..40).flat_map(|x| (8..40).map(move |y| (x, y))).map(|(x, y)| (f64::from(at(d, w, x, y)[0]) - m).powi(2)).sum::<f64>() / 1024.0;
|
||||
(m, var.sqrt())
|
||||
};
|
||||
let (one, three) =
|
||||
(run_fx(&src, w, h, "brushStrokes.crosshatch", json!({})), run_fx(&src, w, h, "brushStrokes.crosshatch", json!({"strength": 3})));
|
||||
let ((m1, s1), (_, s3)) = (spread(&one), spread(&three));
|
||||
assert!((m1 - 128.0).abs() < 25.0 && s1 > 3.0, "{m1} ± {s1}");
|
||||
assert!(s3 > s1, "more passes, more hatching: {s3} vs {s1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_directions_smear_along_themselves() {
|
||||
let (w, h) = (48, 48);
|
||||
// A white line two pixels thick across black.
|
||||
let src = image(w, h, |_, y| if (23..25).contains(&y) { grey(255) } else { grey(0) });
|
||||
let row = |d: &[u8], y: usize| mean_tone(d, w, 12..36, y..y + 1);
|
||||
let along = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0, "strokeDirection": "horizontal"}));
|
||||
let across = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0, "strokeDirection": "vertical"}));
|
||||
assert!(row(&along, 20) < 1.0 && row(&along, 24) > 250.0, "strokes along the line keep it");
|
||||
assert!(row(&across, 20) > 15.0 && row(&across, 24) < 200.0, "strokes across it spread it: {} {}", row(&across, 20), row(&across, 24));
|
||||
// Diagonal strokes spread it too, along the diagonal.
|
||||
let diagonal = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0}));
|
||||
assert!(row(&diagonal, 20) > 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatter_scatters_content_no_farther_than_its_radius() {
|
||||
let (w, h) = (64, 32);
|
||||
// The left half white, the right half transparent.
|
||||
let src = image(w, h, |x, _| if x < 32 { grey(255) } else { [0; 4] });
|
||||
assert_eq!(run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 0})), src, "no radius, no spatter");
|
||||
let f = fx("brushStrokes.spatter", json!({"sprayRadius": 15}));
|
||||
let most = f.reach().unwrap();
|
||||
let d = run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 15}));
|
||||
assert_ne!(d, src);
|
||||
let inside = |v: usize, n: usize| (v as f64 + 0.5).min(n as f64 - v as f64 - 0.5) > most + 1.0;
|
||||
for y in (0..h).filter(|y| inside(*y, h)) {
|
||||
for x in (0..w).filter(|x| inside(*x, w) && (*x as f64 + 0.5 - 32.0).abs() > most + 1.0) {
|
||||
assert_eq!(at(&d, w, x, y), at(&src, w, x, y), "({x}, {y})");
|
||||
}
|
||||
}
|
||||
// The edge is spattered: white specks past it and holes before it.
|
||||
let past = (33..36).flat_map(|x| (8..24).map(move |y| (x, y))).filter(|(x, y)| at(&d, w, *x, *y)[3] > 127).count();
|
||||
let holes = (28..31).flat_map(|x| (8..24).map(move |y| (x, y))).filter(|(x, y)| at(&d, w, *x, *y)[3] < 128).count();
|
||||
assert!(past > 0 && holes > 0, "{past} specks past the edge, {holes} holes before it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_strokes_look_no_farther_than_their_reach() {
|
||||
let (w, h) = (200, 24);
|
||||
let base = image(w, h, |x, y| [(x * 7 % 256) as u8, (y * 11 % 256) as u8, 120, 255]);
|
||||
let cases = brush_strokes().map(|id| (id, json!({}))).chain([
|
||||
("brushStrokes.accentedEdges", json!({"edgeWidth": 14, "smoothness": 15})),
|
||||
("brushStrokes.angledStrokes", json!({"strokeLength": 50})),
|
||||
("brushStrokes.crosshatch", json!({"strokeLength": 50, "strength": 3})),
|
||||
("brushStrokes.inkOutlines", json!({"strokeLength": 50})),
|
||||
("brushStrokes.spatter", json!({"sprayRadius": 25, "smoothness": 15})),
|
||||
("brushStrokes.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25, "strokeDirection": "horizontal"})),
|
||||
("brushStrokes.sumiE", json!({"strokeWidth": 15})),
|
||||
]);
|
||||
for (id, p) in cases {
|
||||
let reach = fx(id, p.clone()).reach().unwrap();
|
||||
// Everything from `cut` on differs; the pixels left of 20 can't tell.
|
||||
let cut = 20 + reach.ceil() as usize + 1;
|
||||
assert!(cut < w, "{id}");
|
||||
let other = image(w, h, |x, y| if x >= cut { [255 - (x * 5 % 256) as u8, 30, (y * 9 % 256) as u8, 200] } else { at(&base, w, x, y) });
|
||||
let (a, b) = (run_fx(&base, w, h, id, p.clone()), run_fx(&other, w, h, id, p.clone()));
|
||||
for (x, y) in (0..20).flat_map(|x| (0..h).map(move |y| (x, y))) {
|
||||
let (pa, pb) = (at(&a, w, x, y), at(&b, w, x, y));
|
||||
assert!(pa.iter().zip(pb).all(|(u, v)| u.abs_diff(v) <= 2), "{id} {p}: ({x}, {y}) {pa:?} vs {pb:?}, reach {reach}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_strokes_follow_the_object_at_any_resolution() {
|
||||
let (w, h) = (48, 48);
|
||||
let art = |x: f64, y: f64| -> [u8; 4] {
|
||||
if (8.0..40.0).contains(&x) && (8.0..40.0).contains(&y) { [(x * 5.0) as u8, (y * 5.0) as u8, 120, 255] } else { [0; 4] }
|
||||
};
|
||||
for id in brush_strokes() {
|
||||
let f = fx(id, json!({}));
|
||||
let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
|
||||
f.apply(&mut one, w, h, &space(w, h));
|
||||
let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
|
||||
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
|
||||
f.apply(&mut two, 2 * w, 2 * h, &fine);
|
||||
let down = image(w, h, |x, y| {
|
||||
let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy));
|
||||
std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::<u32>() / 4) as u8)
|
||||
});
|
||||
let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::<f64>() / one.len() as f64;
|
||||
assert!(diff < 12.0, "{id}: {diff}");
|
||||
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(31.0, 27.0), ..space(w, h) };
|
||||
let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
|
||||
f.apply(&mut shifted, w, h, &moved);
|
||||
assert_eq!(shifted, one, "{id}");
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ impl Texture {
|
||||
}
|
||||
|
||||
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
|
||||
fn height(self, p: Point) -> f64 {
|
||||
pub(super) fn height(self, p: Point) -> f64 {
|
||||
match self {
|
||||
Texture::Brick => brick(p),
|
||||
Texture::Burlap => weave(p, 7.0, true),
|
||||
@@ -155,13 +155,13 @@ const SALT_SURFACE: u64 = 112;
|
||||
pub(super) const CONTOUR_SOFTNESS: f64 = 1.0 / 6.0;
|
||||
|
||||
/// A pixel's straight colour (0..1).
|
||||
fn straight(p: [u8; 4]) -> [f32; 3] {
|
||||
pub(super) fn straight(p: [u8; 4]) -> [f32; 3] {
|
||||
let a = f32::from(p[3]);
|
||||
[p[0], p[1], p[2]].map(|v| if a > 0.0 { (f32::from(v) / a).min(1.0) } else { 0.0 })
|
||||
}
|
||||
|
||||
/// The pixel of straight colour `c` at coverage `alpha` (0..1), premultiplied.
|
||||
fn premultiply(c: [f32; 3], alpha: f32) -> [u8; 4] {
|
||||
pub(super) fn premultiply(c: [f32; 3], alpha: f32) -> [u8; 4] {
|
||||
let a = (alpha.clamp(0.0, 1.0) * 255.0).round();
|
||||
let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * a).round().min(a) as u8);
|
||||
[r, g, b, a as u8]
|
||||
@@ -185,7 +185,7 @@ fn cover(d: f64, px: f64) -> f64 {
|
||||
}
|
||||
|
||||
/// The luma of colour `c` (Rec. 601 weights).
|
||||
fn luma(c: [f32; 3]) -> f32 {
|
||||
pub(super) fn luma(c: [f32; 3]) -> f32 {
|
||||
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
|
||||
}
|
||||
|
||||
@@ -233,14 +233,14 @@ fn bevel(d: f64, width: f64) -> f64 {
|
||||
|
||||
/// The object's straight luminance (0..1) softened by a Gaussian of `sigma` pixels, weighted by
|
||||
/// coverage so the transparency around it doesn't darken its edges.
|
||||
struct Tones {
|
||||
pub(super) struct Tones {
|
||||
v: Vec<f32>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
impl Tones {
|
||||
fn new(px: &[[u8; 4]], w: usize, h: usize, sigma: f64) -> Self {
|
||||
pub(super) fn new(px: &[[u8; 4]], w: usize, h: usize, sigma: f64) -> Self {
|
||||
let mut l: Vec<f32> = px.iter().map(|p| luma([p[0], p[1], p[2]].map(f32::from)) / 255.0).collect();
|
||||
let mut a: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
blur_plane(&mut l, w, h, sigma);
|
||||
@@ -249,7 +249,7 @@ impl Tones {
|
||||
Self { v, w, h }
|
||||
}
|
||||
|
||||
fn get(&self, x: usize, y: usize) -> f64 {
|
||||
pub(super) fn get(&self, x: usize, y: usize) -> f64 {
|
||||
let (x, y) = (x.min(self.w.saturating_sub(1)), y.min(self.h.saturating_sub(1)));
|
||||
self.v.get(y * self.w + x).map_or(0.0, |v| f64::from(*v))
|
||||
}
|
||||
@@ -308,7 +308,7 @@ pub(super) fn craquelure(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSp
|
||||
}
|
||||
|
||||
/// A random value in [-1, 1) for the `size`-point cell holding `q`; `k` picks one of several.
|
||||
fn cell_noise(salt: u64, q: Point, size: f64, k: u64) -> f64 {
|
||||
pub(super) fn cell_noise(salt: u64, q: Point, size: f64, k: u64) -> f64 {
|
||||
match (cell(q.x / size), cell(q.y / size)) {
|
||||
(Some(i), Some(j)) => 2.0 * rand(salt, i, j, k) - 1.0,
|
||||
_ => 0.0,
|
||||
@@ -316,7 +316,7 @@ fn cell_noise(salt: u64, q: Point, size: f64, k: u64) -> f64 {
|
||||
}
|
||||
|
||||
/// [`value_noise`] in [-1, 1) over cells `size` points wide.
|
||||
fn smooth_noise(salt: u64, q: Point, size: f64) -> f64 {
|
||||
pub(super) fn smooth_noise(salt: u64, q: Point, size: f64) -> f64 {
|
||||
2.0 * value_noise(salt, q.x / size, q.y / size) - 1.0
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Revolve as live, shaded vector art, shared by the canvas and every exporter.
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{PathData, Rect};
|
||||
use vectorcraft_three_d::{Edge, Revolve};
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params};
|
||||
|
||||
pub const REVOLVE: &str = "threeD.revolve";
|
||||
|
||||
fn visible(fx: &[Effect]) -> bool {
|
||||
fx.iter().any(|e| e.visible && e.id == REVOLVE)
|
||||
}
|
||||
|
||||
pub fn has_revolve(n: &Node) -> bool {
|
||||
visible(&n.appearance.effects) || n.appearance.items.iter().any(|i| i.visible() && visible(i.effects()))
|
||||
}
|
||||
|
||||
/// Shared document-parameter conversion for rendering and interactive preview guides.
|
||||
pub fn revolve_options(p: &Value) -> Revolve {
|
||||
Revolve {
|
||||
angle: num(p, "angle", 360.0),
|
||||
offset: num(p, "offset", 0.0),
|
||||
edge: if text(p, "edge", "left") == "right" { Edge::Right } else { Edge::Left },
|
||||
rotation_x: num(p, "rotationX", 0.0),
|
||||
rotation_y: num(p, "rotationY", 0.0),
|
||||
rotation_z: num(p, "rotationZ", 0.0),
|
||||
perspective: num(p, "perspective", 0.0) / 100.0,
|
||||
segments: num(p, "segments", 64.0).clamp(8.0, 128.0) as usize,
|
||||
light_azimuth: num(p, "lightAzimuth", -45.0),
|
||||
light_elevation: num(p, "lightElevation", 45.0),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
shade: flag(p, "shade", true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate before a command mutates the document. Loading external files is still bounded
|
||||
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
|
||||
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
|
||||
let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?;
|
||||
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
|
||||
if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) {
|
||||
return Err("Revolve requires at least two anchors".into());
|
||||
}
|
||||
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
|
||||
return Err("Revolve edge must be left or right".into());
|
||||
}
|
||||
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
|
||||
}
|
||||
|
||||
fn profile(n: &Node) -> Option<PathData> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, guide: false, clipping: false, .. } => Some(path.clone()),
|
||||
NodeKind::Compound { children, .. } => {
|
||||
Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn surface(
|
||||
n: &Node,
|
||||
path: &PathData,
|
||||
fx: &Effect,
|
||||
paint: Paint,
|
||||
opacity: f32,
|
||||
blend: vectorcraft_doc::color::BlendMode,
|
||||
expand: bool,
|
||||
) -> Option<Node> {
|
||||
let b = path.bounds()?;
|
||||
// A loaded file or a profile edited past the limits keeps drawing its source path instead
|
||||
// of crashing the frame. Commands validate the same evaluator and report its errors.
|
||||
let faces = vectorcraft_three_d::revolve(path, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?;
|
||||
// Visibility is an expansion option, never a change to live geometry or export baking.
|
||||
// Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility
|
||||
// failure also retains the complete surface rather than losing any of the user's art.
|
||||
let visible = (expand
|
||||
&& flag(&fx.params, "expandVisibleOnly", true)
|
||||
&& paint.color().is_some()
|
||||
&& opacity == 1.0
|
||||
&& blend == Default::default()
|
||||
&& n.has_default_transparency())
|
||||
.then(|| vectorcraft_three_d::visible_faces(&faces).ok())
|
||||
.flatten();
|
||||
let faces = visible.unwrap_or_else(|| {
|
||||
faces.into_iter().map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth }).collect()
|
||||
});
|
||||
let children = faces
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
let mut fill = FillLayer::new(paint.clone());
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let s = face.brightness as f32;
|
||||
fill.paint = Paint::solid(Color::rgb(r * s, g * s, b * s));
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
} else {
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
items.push(AppearanceItem::Fill(shadow));
|
||||
}
|
||||
// Independently antialiased adjacent polygons otherwise leave a hairline grid.
|
||||
// Matching narrow edge paint overlaps their coverage in the canvas and exporters.
|
||||
// Keep opacity on the surface group so these edges never accumulate transparency.
|
||||
let items = items
|
||||
.into_iter()
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75);
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
.collect();
|
||||
Arc::new(Node::path(n.id, face.path, Appearance { items, ..Default::default() }))
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(n.id, children);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
Some(group)
|
||||
}
|
||||
|
||||
fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
|
||||
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluated art keeps the source's identity and transparency. Each item's opacity belongs
|
||||
/// to its entire surface, not to each face, so overlapping faces don't accumulate alpha.
|
||||
pub fn revolve_art(n: &Node) -> Option<Node> {
|
||||
art(n, false)
|
||||
}
|
||||
|
||||
/// Revolve's own Expand Appearance path. The shared command and UI remain unchanged.
|
||||
pub(crate) fn expand_revolve_art(n: &Node) -> Option<Node> {
|
||||
art(n, true)
|
||||
}
|
||||
|
||||
fn art(n: &Node, expand: bool) -> Option<Node> {
|
||||
if !has_revolve(n) {
|
||||
return None;
|
||||
}
|
||||
let path = profile(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let mut children = vec![];
|
||||
let mut remaining = n.appearance.effects.clone();
|
||||
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
|
||||
let fx = n.appearance.effects.get(index)?;
|
||||
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
|
||||
// An object's surface uses its top visible fill, falling back to its stroke.
|
||||
let item = n
|
||||
.appearance
|
||||
.items
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i))
|
||||
.or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))?;
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
|
||||
children.push(Arc::new(surface(n, &path, fx, paint, opacity, blend, expand)?));
|
||||
// Later geometry acts on the projected art as a whole through container evaluation.
|
||||
remaining = n
|
||||
.appearance
|
||||
.effects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, e)| e.id != REVOLVE && (!is_geometry(&e.id) || *i > index))
|
||||
.map(|(_, e)| e.clone())
|
||||
.collect();
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
remaining.retain(|e| !is_geometry(&e.id));
|
||||
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
|
||||
if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) {
|
||||
let fx = item.effects().get(index)?;
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
|
||||
let mut art = surface(n, &p, fx, paint, opacity, blend, expand)?;
|
||||
art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect();
|
||||
children.push(Arc::new(art));
|
||||
} else {
|
||||
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut art = n.clone();
|
||||
art.kind = NodeKind::Group { children, clip: false };
|
||||
art.appearance = Appearance { effects: remaining, ..Default::default() };
|
||||
Some(art)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Affine, BezPath};
|
||||
fn profile_node() -> Node {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 40.0));
|
||||
b.line_to((130.0, 60.0));
|
||||
b.line_to((130.0, 140.0));
|
||||
b.line_to((100.0, 160.0));
|
||||
Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0))
|
||||
}
|
||||
fn fx() -> Effect {
|
||||
crate::new_effect(REVOLVE, &json!({})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() {
|
||||
let mut n = profile_node();
|
||||
let before = n.path_data().unwrap().clone();
|
||||
n.appearance.effects.push(fx());
|
||||
let a = revolve_art(&n).unwrap();
|
||||
assert!(!a.children().unwrap().is_empty());
|
||||
assert_eq!(n.path_data().unwrap(), &before);
|
||||
let b = a.visual_bounds().unwrap();
|
||||
n.transform(Affine::translate((80.0, 20.0)), false);
|
||||
let moved = revolve_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((moved.x0 - b.x0 - 80.0).abs() < 1e-6 && (moved.y0 - b.y0 - 20.0).abs() < 1e-6);
|
||||
n.appearance.effects[0].visible = false;
|
||||
assert!(revolve_art(&n).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn item_effect_and_transparency_are_scoped_to_whole_surface() {
|
||||
let mut n = profile_node();
|
||||
n.opacity = 0.6;
|
||||
n.appearance.items[0].effects_mut().push(fx());
|
||||
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
|
||||
f.opacity = 0.4;
|
||||
}
|
||||
let a = revolve_art(&n).unwrap();
|
||||
assert_eq!(a.opacity, 0.6);
|
||||
let surface = &a.children().unwrap()[0];
|
||||
assert_eq!(surface.opacity, 0.4);
|
||||
assert!(surface.children().unwrap().iter().all(|c| c.opacity == 1.0 && !has_revolve(c)));
|
||||
}
|
||||
#[test]
|
||||
fn export_bakes_faces_with_unique_ids_and_no_revolve_effects() {
|
||||
let mut d = Document::new(300.0, 300.0);
|
||||
let mut n = profile_node();
|
||||
n.id = d.alloc_id();
|
||||
n.appearance.effects.push(fx());
|
||||
let mut stroke = |_: &mut Document, _: &PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None;
|
||||
let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
|
||||
assert!(ids.insert(n.id));
|
||||
assert!(!has_revolve(n));
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, ids);
|
||||
}
|
||||
}
|
||||
visit(&art, &mut ids);
|
||||
assert!(ids.len() > 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_surface_option_only_affects_expansion_and_can_keep_all_faces() {
|
||||
let mut n = profile_node();
|
||||
n.appearance.effects.push(fx());
|
||||
let source = n.path_data().unwrap().clone();
|
||||
let live = revolve_art(&n).unwrap();
|
||||
let expanded = expand_revolve_art(&n).unwrap();
|
||||
let count = |n: &Node| n.children().unwrap()[0].children().unwrap().len();
|
||||
assert!(count(&expanded) < count(&live) * 3 / 4, "hidden back surfaces are removed");
|
||||
assert_eq!(crate::bake_appearance(&n), Some(live.clone()), "other baking retains the full surface");
|
||||
n.appearance.effects[0].params["expandVisibleOnly"] = json!(false);
|
||||
assert_eq!(revolve_art(&n), Some(live.clone()), "the live view ignores the expansion setting");
|
||||
assert_eq!(expand_revolve_art(&n), Some(live));
|
||||
assert_eq!(n.path_data(), Some(&source));
|
||||
n.appearance.effects[0].params["expandVisibleOnly"] = json!(true);
|
||||
assert_eq!(expand_revolve_art(&n), Some(expanded.clone()));
|
||||
// Per-item Revolve uses the same option, without trimming another item's artwork.
|
||||
let effect = n.appearance.effects.remove(0);
|
||||
n.appearance.items[0].effects_mut().push(effect);
|
||||
assert_eq!(expand_revolve_art(&n).unwrap().children().unwrap()[0], expanded.children().unwrap()[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() {
|
||||
let mut n = profile_node();
|
||||
n.appearance.effects.push(fx());
|
||||
for change in 0..4 {
|
||||
let mut sample = n.clone();
|
||||
match change {
|
||||
0 => sample.opacity = 0.5,
|
||||
1 => {
|
||||
if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] {
|
||||
f.opacity = 0.4;
|
||||
}
|
||||
}
|
||||
2 => sample.blend = vectorcraft_doc::color::BlendMode::Multiply,
|
||||
_ => sample.appearance.effects.push(crate::new_effect("distort.twist", &json!({"angle":45})).unwrap()),
|
||||
}
|
||||
assert_eq!(expand_revolve_art(&sample), revolve_art(&sample), "preserve surface for case {change}");
|
||||
}
|
||||
let mut sample = n;
|
||||
if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] {
|
||||
f.paint = Paint::Pattern { pattern: "unknown transparency".into(), xf: Affine::IDENTITY };
|
||||
}
|
||||
assert_eq!(expand_revolve_art(&sample), revolve_art(&sample));
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,7 @@ fn edit_effects(
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let index = item.effects_item(&n.appearance, cmd)?;
|
||||
if n.appearance.effects_mut(index).is_some_and(&mut f) {
|
||||
check_revolve_count(n, cmd)?;
|
||||
done.push(*id);
|
||||
}
|
||||
}
|
||||
@@ -117,6 +118,14 @@ fn edit_effects(
|
||||
})
|
||||
}
|
||||
|
||||
fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> {
|
||||
let count = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects())).filter(|e| e.id == effects::REVOLVE).count();
|
||||
if count > 1 {
|
||||
return Err(bad(cmd, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.apply";
|
||||
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
|
||||
@@ -140,6 +149,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
if id == effects::REVOLVE {
|
||||
for id in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?;
|
||||
if effects::has_revolve(n) {
|
||||
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut index = 0;
|
||||
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
|
||||
fx.push(effect.clone());
|
||||
@@ -237,6 +255,7 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(fx) = n.appearance.effects_mut(di) else { continue };
|
||||
let at = to.min(fx.len());
|
||||
fx.insert(at, e);
|
||||
check_revolve_count(n, C)?;
|
||||
landed.get_or_insert((at, di));
|
||||
done.push(*id);
|
||||
}
|
||||
@@ -258,6 +277,20 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let visible = p.get("visible").and_then(Value::as_bool);
|
||||
let item = item_target(s, p, C)?;
|
||||
for id in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let target = item.effects_item(&n.appearance, C)?;
|
||||
if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index))
|
||||
&& e.id == effects::REVOLVE
|
||||
&& visible != Some(false)
|
||||
{
|
||||
let mut prospective = effects::merged_params(&e.id, &e.params);
|
||||
if let (Some(cur), Some(new)) = (prospective.as_object_mut(), params.as_object()) {
|
||||
cur.extend(new.clone());
|
||||
}
|
||||
effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
|
||||
let Some(e) = fx.get_mut(index) else { return false };
|
||||
if let (Value::Object(new), cur) = (¶ms, &mut e.params) {
|
||||
@@ -454,6 +487,7 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use crate::{NodeId, Session};
|
||||
|
||||
@@ -468,6 +502,71 @@ mod tests {
|
||||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let source = node(&s, id).path_data().unwrap().clone();
|
||||
s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap();
|
||||
let before = effects_for_test(&s, id);
|
||||
assert_eq!(node(&s, id).path_data(), Some(&source));
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
assert_eq!(restored.node(id).unwrap().path_data(), Some(&source));
|
||||
assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), before);
|
||||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF"));
|
||||
assert!(s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).is_err());
|
||||
assert!(s.execute("effect.duplicate", &json!({"index":0,"ids":[id.0],"item":null})).is_err());
|
||||
s.execute("effect.setParams", &json!({"index":0,"params":{"angle":180,"offset":10},"ids":[id.0],"item":null})).unwrap();
|
||||
assert_ne!(effects_for_test(&s, id), before);
|
||||
assert!(s.execute("effect.setParams", &json!({"index":0,"params":{"edge":"invalid"},"ids":[id.0],"item":null})).is_err());
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }));
|
||||
assert!(!effects::has_revolve(&node(&s, id)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id).path_data(), Some(&source));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(effects_for_test(&s, id), before);
|
||||
}
|
||||
|
||||
fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||||
effects::revolve_art(&node(s, id)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap();
|
||||
let live = node(&s, id);
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true));
|
||||
let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len();
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
let visible = count(&node(&s, id));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), live);
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"ids":[id.0],"params":{"expandVisibleOnly":false}})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
assert!(count(&node(&s, id)) > visible);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id).appearance.effects[0].params["expandVisibleOnly"], json!(false));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), live);
|
||||
// The shared command still expands ordinary multiple-fill artwork as before.
|
||||
let r = s.execute("shape.rectangle", &json!({"x":10,"y":10,"width":20,"height":20})).unwrap();
|
||||
let ordinary = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect":"distort.twist","ids":[ordinary.0],"params":{"angle":15}})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[ordinary.0]})).unwrap();
|
||||
assert!(node(&s, ordinary).appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_uses_catalog_defaults_and_undoes() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
|
||||
+1078
-121
File diff suppressed because it is too large.
Load diff
@@ -52,6 +52,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
update
|
||||
),
|
||||
cmd!(
|
||||
"links.updateChanged",
|
||||
"Update Changed Links",
|
||||
[],
|
||||
None,
|
||||
"{} read again the active document's linked files that changed on disk since they were last seen (size or modification time), whatever Preferences → File Handling → Update Links says; the desktop app looks every two seconds and follows that preference. Files seen for the first time are only remembered. One undo step → {updated: [ids], ask: [], modified: []}",
|
||||
has_doc,
|
||||
crate::link_watch::update_changed
|
||||
),
|
||||
cmd!(
|
||||
"links.relink",
|
||||
"Relink",
|
||||
@@ -158,6 +167,11 @@ fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<bool>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]).
|
||||
pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec<u64>)> {
|
||||
groups(d, None).into_iter().filter(|g| !g.ids.is_empty()).map(|g| (g.link.path, ids_json(&g.ids))).collect()
|
||||
}
|
||||
|
||||
/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when
|
||||
/// given), then, without `ids`, the ones only symbols and patterns show.
|
||||
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
||||
|
||||
@@ -77,6 +77,7 @@ pub(crate) mod textwrap;
|
||||
pub(crate) mod threads;
|
||||
pub(crate) mod typecmd;
|
||||
mod typemenu;
|
||||
mod variablecmds;
|
||||
pub(crate) mod views;
|
||||
pub mod wand;
|
||||
pub mod webexport;
|
||||
@@ -235,6 +236,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(maskedit::specs());
|
||||
v.extend(tabs::specs());
|
||||
v.extend(docmenu::specs());
|
||||
v.extend(variablecmds::specs());
|
||||
v.extend(docinfo::specs());
|
||||
v.extend(panelcmds::specs());
|
||||
v.extend(buildcmds::specs());
|
||||
|
||||
@@ -278,6 +278,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("toolGroupLabels", "User Interface", "", "Show Tool Group Labels", bool),
|
||||
p!("openDocumentsAsTabs", "User Interface", "", "Open Documents As Tabs", bool),
|
||||
p!("largeTabs", "User Interface", "", "Large Tabs", bool),
|
||||
p!("systemTitleBar", "User Interface", "", "System Title Bar", bool),
|
||||
p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")),
|
||||
p!("scaleCursorWithUi", "User Interface", "UI Scaling", "Scale Cursor Proportional to UI", bool),
|
||||
// `auto` or a language code the shell registers (`zh-hant`); the shell shows it as a dropdown.
|
||||
|
||||
@@ -112,6 +112,23 @@ fn text_ref(s: &Session, id: NodeId) -> Result<TextObject> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace a type object's whole content: the new text takes the replaced text's
|
||||
/// style and paragraph styles follow, exactly as `text.editRange` over the full
|
||||
/// range does. Dataset apply and friends share this so Variables edits behave
|
||||
/// like Type tool edits.
|
||||
pub(crate) fn set_plain_text(d: &mut vectorcraft_doc::Document, id: NodeId, text: &str) -> Result<()> {
|
||||
let len = match d.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => edit::runs_len(&t.runs),
|
||||
Some(_) => return Err(EngineError::Other(format!("node {} is not text", id.0))),
|
||||
None => return Err(EngineError::NoNode(id)),
|
||||
};
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
t.splice_paras(0, len, text);
|
||||
edit::replace_range(&mut t.runs, 0, len, text);
|
||||
refresh_bounds(t);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn byte_param(p: &Value, k: &str) -> Option<usize> {
|
||||
p.get(k).and_then(Value::as_u64).map(|v| v as usize)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
//! Variables (data merge): named values bound to objects, applied per dataset.
|
||||
//!
|
||||
//! One template document produces many variants: bind a type object to a `text`
|
||||
//! variable and anything to a `visibility` variable, put a row of values in each
|
||||
//! dataset, and `dataset.select` swaps the whole document to that row in one undo
|
||||
//! step. Agents reach every command through `run_command`, like all engine commands.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{DataSet, DataValue, NodeId, NodeKind, Variable, VariableKind, Variables};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::textedit::set_plain_text;
|
||||
use super::*;
|
||||
|
||||
/// The longest variable or dataset name, in characters (names arrive from MCP and files).
|
||||
const MAX_NAME: usize = 255;
|
||||
|
||||
fn var_name(p: &Value, cmd: &str) -> Result<String> {
|
||||
named(str_param(p, "name"), "name", cmd)
|
||||
}
|
||||
|
||||
/// `key`'s value, as a name: trimmed, not empty and at most [`MAX_NAME`] characters.
|
||||
fn named(v: Option<&str>, key: &str, cmd: &str) -> Result<String> {
|
||||
match v.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
Some(v) if v.chars().count() > MAX_NAME => Err(bad(cmd, format!("`{key}` is longer than {MAX_NAME} characters"))),
|
||||
Some(v) => Ok(v.to_string()),
|
||||
None => Err(bad(cmd, format!("missing `{key}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `key`'s value as a name, or `None` when it is missing or empty.
|
||||
fn optional_name(p: &Value, key: &str, cmd: &str) -> Result<Option<String>> {
|
||||
match str_param(p, key).map(str::trim).filter(|v| !v.is_empty()) {
|
||||
Some(v) => named(Some(v), key, cmd).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `names` of a delete, or without them `fallback` (what the panel acts on) as the one name.
|
||||
fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) -> Result<Vec<String>> {
|
||||
let Some(given) = p.get("names") else {
|
||||
return fallback.map(|n| vec![n]).ok_or_else(|| bad(cmd, nothing.to_string()));
|
||||
};
|
||||
match given.as_array() {
|
||||
Some(names) if !names.is_empty() => names.iter().map(|n| named(n.as_str(), "names", cmd)).collect(),
|
||||
_ => Err(bad(cmd, "`names` must be a list of names")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects a bind acts on: `ids` when given (every one checked), else the selection.
|
||||
fn target_ids(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", cmd) } else { selected_roots(s) }
|
||||
}
|
||||
|
||||
/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked
|
||||
/// layer is not the document's to rewrite, and a text variable only drives type.
|
||||
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
|
||||
for id in ids {
|
||||
match doc.node(*id) {
|
||||
None => return Err(EngineError::NoNode(*id)),
|
||||
Some(_) if !doc.variable_target(*id) => return Err(bad(cmd, format!("node {} is locked", id.0))),
|
||||
Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => {
|
||||
return Err(bad(cmd, format!("node {} is not type", id.0)));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result<DataValue> {
|
||||
match kind {
|
||||
VariableKind::Text => v.as_str().map(|t| DataValue::Text(t.to_string())),
|
||||
VariableKind::Visibility => v.as_bool().map(DataValue::Visible),
|
||||
}
|
||||
.ok_or_else(|| bad(cmd, format!("value for `{var}` must match its variable kind")))
|
||||
}
|
||||
|
||||
/// The `values` object of `dataset.new` / `dataset.set`, each entry checked against its
|
||||
/// variable's kind. Every name must be a variable the document defines: a value nothing
|
||||
/// reads is a typo, not a value to keep.
|
||||
fn values_param(vars: &Variables, p: &Value, cmd: &str) -> Result<BTreeMap<String, DataValue>> {
|
||||
let mut out = BTreeMap::new();
|
||||
let Some(given) = p.get("values") else { return Ok(out) };
|
||||
let Some(obj) = given.as_object() else { return Err(bad(cmd, "`values` must be an object")) };
|
||||
for (var, v) in obj {
|
||||
let kind = vars.variable(var).map(|d| d.kind).ok_or_else(|| bad(cmd, format!("no variable named `{var}`")))?;
|
||||
out.insert(var.clone(), dataset_value(cmd, var, kind, v)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn define(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.define";
|
||||
let name = var_name(p, C)?;
|
||||
let kind = str_param(p, "kind").and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text` or `visibility`"))?;
|
||||
if s.doc()?.doc.variables.variable(&name).is_some() {
|
||||
return Err(bad(C, format!("variable `{name}` is already defined")));
|
||||
}
|
||||
s.edit("New Variable", |d, _| {
|
||||
d.variables.variables.push(Variable { name: name.clone(), kind });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name, "kind": kind.label()}))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.rename";
|
||||
let from = var_name(p, C)?;
|
||||
let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?;
|
||||
let st = s.doc()?;
|
||||
if st.doc.variables.variable(&to).is_some() {
|
||||
return Err(bad(C, format!("variable `{to}` already exists")));
|
||||
}
|
||||
s.edit("Rename Variable", |d, _| {
|
||||
let v = d.variables.variables.iter_mut().find(|v| v.name == from).ok_or_else(|| bad(C, format!("no variable named `{from}`")))?;
|
||||
v.name = to.clone();
|
||||
for bound in d.variables.bindings.values_mut() {
|
||||
if bound.remove(&from) {
|
||||
bound.insert(to.clone());
|
||||
}
|
||||
}
|
||||
for ds in &mut d.variables.datasets {
|
||||
if let Some(value) = ds.values.remove(&from) {
|
||||
ds.values.insert(to.clone(), value);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// The highlight names a variable, so it follows the rename rather than going stale.
|
||||
let st = s.doc_mut()?;
|
||||
if st.variables_highlight.as_deref() == Some(from.as_str()) {
|
||||
st.variables_highlight = Some(to.clone());
|
||||
}
|
||||
st.revision += 1;
|
||||
Ok(json!({"from": from, "name": to}))
|
||||
}
|
||||
|
||||
/// Make Text Dynamic / Make Visibility Dynamic: define a variable of that kind and bind the
|
||||
/// selection to it in one step, naming it after what it drives — the panel's buttons.
|
||||
fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result<Value> {
|
||||
const C: &str = "variable.makeDynamic";
|
||||
let ids = selected_roots(s)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select the objects to bind"));
|
||||
}
|
||||
let doc = &s.doc()?.doc;
|
||||
check_bindable(doc, &ids, kind, C)?;
|
||||
// Named after the first object it drives, as the reference panel does; a second variable
|
||||
// over the same art takes a number rather than colliding.
|
||||
let shown = ids.first().and_then(|id| doc.node(*id)).map(|n| n.display_name()).unwrap_or_default();
|
||||
let base: String = match shown.trim() {
|
||||
"" => stem.to_string(),
|
||||
b => b.chars().take(MAX_NAME - 8).collect(),
|
||||
};
|
||||
let mut name = base.clone();
|
||||
let mut n = 2;
|
||||
while doc.variables.variable(&name).is_some() {
|
||||
name = format!("{base} {n}");
|
||||
n += 1;
|
||||
}
|
||||
s.edit("Make Dynamic", |d, _| {
|
||||
d.variables.variables.push(Variable { name: name.clone(), kind });
|
||||
for id in &ids {
|
||||
d.variables.bind(*id, &name);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name, "kind": kind.label(), "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Without `names`, the highlighted variable: what the panel's bin and the menu item delete.
|
||||
let highlighted = s.doc()?.variables_highlight.clone();
|
||||
let names = names_param(p, "variable.delete", highlighted, "no variable highlighted")?;
|
||||
let n = s.edit("Delete Variables", |d, _| Ok(d.variables.prune(&names)))?;
|
||||
// A highlight of a variable that is gone highlights nothing.
|
||||
let st = s.doc_mut()?;
|
||||
if st.variables_highlight.as_ref().is_some_and(|h| st.doc.variables.variable(h).is_none()) {
|
||||
st.variables_highlight = None;
|
||||
}
|
||||
st.revision += 1;
|
||||
Ok(json!({"deleted": n}))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let variables: Vec<Value> = st
|
||||
.doc
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let bindings = st.doc.variables.objects_of(&v.name).len();
|
||||
json!({"name": v.name, "kind": v.kind.label(), "bindings": bindings})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"variables": variables}))
|
||||
}
|
||||
|
||||
fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.bind";
|
||||
let name = named(str_param(p, "variable"), "variable", C)?;
|
||||
let ids = target_ids(s, p, C)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "nothing to bind"));
|
||||
}
|
||||
let doc = &s.doc()?.doc;
|
||||
let kind = doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?;
|
||||
check_bindable(doc, &ids, kind, C)?;
|
||||
// Binding again is not a second binding, and says so rather than counting as a change.
|
||||
let already: Vec<u64> = ids.iter().filter(|id| doc.variables.objects_of(&name).contains(id)).map(|id| id.0).collect();
|
||||
let n = s.edit("Bind Variable", |d, _| Ok(ids.iter().filter(|id| d.variables.bind(**id, &name)).count()))?;
|
||||
Ok(json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "bound": n, "already": already}))
|
||||
}
|
||||
|
||||
fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.unbind";
|
||||
let ids = target_ids(s, p, C)?;
|
||||
let name = defined_variable(s, p, C)?;
|
||||
let n = s.edit("Unbind Variable", |d, _| {
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
// With `variable` only that one binding goes; without it, every one the object has.
|
||||
match &name {
|
||||
Some(v) => n += usize::from(d.variables.unbind(*id, v)),
|
||||
None => n += usize::from(d.variables.bindings.remove(id).is_some()),
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"unbound": n}))
|
||||
}
|
||||
|
||||
/// The `variable` a command names, which must be defined; `None` when it names none.
|
||||
fn defined_variable(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
|
||||
let name = optional_name(p, "variable", cmd)?;
|
||||
if let Some(v) = &name
|
||||
&& s.doc()?.doc.variables.variable(v).is_none()
|
||||
{
|
||||
return Err(bad(cmd, format!("no variable named `{v}`")));
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Highlight one Variables panel row: what the panel's Delete, Options… and Select Bound
|
||||
/// Object act on. Panel state, not art selection: not saved, not an undo step (an empty
|
||||
/// or missing `variable` clears it), like `layer.highlight`.
|
||||
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = defined_variable(s, p, "variable.highlight")?;
|
||||
let st = s.doc_mut()?;
|
||||
st.variables_highlight = name.clone();
|
||||
st.revision += 1;
|
||||
Ok(json!({"variable": name}))
|
||||
}
|
||||
|
||||
fn dataset_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.new";
|
||||
let name = var_name(p, C)?;
|
||||
let st = s.doc()?;
|
||||
// A dataset and a variable are separate namespaces, so a dataset may share a variable's
|
||||
// name; it is its own row of values either way.
|
||||
let values = values_param(&st.doc.variables, p, C)?;
|
||||
if st.doc.variables.dataset(&name).is_some() {
|
||||
return Err(bad(C, format!("dataset `{name}` already exists")));
|
||||
}
|
||||
s.edit("New Data Set", |d, _| {
|
||||
d.variables.datasets.push(DataSet { name: name.clone(), values: values.clone() });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
/// Replace a dataset's values. A variable left out of `values` loses its value in this row,
|
||||
/// so the dataset ends up holding exactly what the call asked for.
|
||||
fn dataset_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.set";
|
||||
let name = var_name(p, C)?;
|
||||
let values = values_param(&s.doc()?.doc.variables, p, C)?;
|
||||
let (set, removed) = s.edit("Edit Data Set", |d, _| {
|
||||
let ds = d.variables.datasets.iter_mut().find(|x| x.name == name).ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
let removed: Vec<String> = ds.values.keys().filter(|k| !values.contains_key(*k)).cloned().collect();
|
||||
ds.values = values.clone();
|
||||
Ok((values.len(), removed))
|
||||
})?;
|
||||
Ok(json!({"name": name, "values": set, "removed": removed}))
|
||||
}
|
||||
|
||||
fn dataset_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.rename";
|
||||
let from = var_name(p, C)?;
|
||||
let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?;
|
||||
let st = s.doc()?;
|
||||
if st.doc.variables.dataset(&to).is_some() {
|
||||
return Err(bad(C, format!("dataset `{to}` already exists")));
|
||||
}
|
||||
s.edit("Rename Data Set", |d, _| {
|
||||
let ds = d.variables.datasets.iter_mut().find(|d| d.name == from).ok_or_else(|| bad(C, format!("no dataset named `{from}`")))?;
|
||||
ds.name = to.clone();
|
||||
if d.variables.active_dataset.as_deref() == Some(from.as_str()) {
|
||||
d.variables.active_dataset = Some(to.clone());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"from": from, "name": to}))
|
||||
}
|
||||
|
||||
/// Capture Data Set: record what the bound objects hold right now as a new row. This, rather
|
||||
/// than typing values, is how rows are made — change the art, then capture it.
|
||||
fn dataset_capture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.capture";
|
||||
let st = s.doc()?;
|
||||
let values = st.doc.captured_values();
|
||||
if values.is_empty() {
|
||||
return Err(bad(C, "bind something to a variable first"));
|
||||
}
|
||||
let name = match optional_name(p, "name", C)? {
|
||||
Some(n) => n,
|
||||
// "Data Set 1", the first free number, as the reference panel's rows are named.
|
||||
None => (1..).map(|i| format!("Data Set {i}")).find(|n| st.doc.variables.dataset(n).is_none()).unwrap_or_default(),
|
||||
};
|
||||
if s.doc()?.doc.variables.dataset(&name).is_some() {
|
||||
return Err(bad(C, format!("dataset `{name}` already exists")));
|
||||
}
|
||||
s.edit("Capture Data Set", |d, _| {
|
||||
d.variables.datasets.push(DataSet { name: name.clone(), values: values.clone() });
|
||||
d.variables.active_dataset = Some(name.clone());
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name, "values": values.len()}))
|
||||
}
|
||||
|
||||
/// Update Data Set: write what the bound objects hold now into the row that is active. Without
|
||||
/// an active row there is nothing to update, so say so rather than picking one.
|
||||
fn dataset_update(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.update";
|
||||
let values = s.doc()?.doc.captured_values();
|
||||
let Some(name) = s.doc()?.doc.variables.active_dataset.clone() else {
|
||||
return Err(bad(C, "no active data set"));
|
||||
};
|
||||
if values.is_empty() {
|
||||
return Err(bad(C, "bind something to a variable first"));
|
||||
}
|
||||
s.edit("Update Data Set", |d, _| {
|
||||
let ds = d.variables.datasets.iter_mut().find(|d| d.name == name).ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
ds.values = values.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name, "values": values.len()}))
|
||||
}
|
||||
|
||||
fn dataset_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Without `names`, the active dataset: what the panel's Delete Data Set deletes.
|
||||
let active = s.doc()?.doc.variables.active_dataset.clone();
|
||||
let names = names_param(p, "dataset.delete", active, "no active data set")?;
|
||||
let n = s.edit("Delete Data Sets", |d, _| {
|
||||
let before = d.variables.datasets.len();
|
||||
d.variables.datasets.retain(|d| !names.contains(&d.name));
|
||||
// The active dataset's own name is the one that goes, not a variable's.
|
||||
if d.variables.active_dataset.as_ref().is_some_and(|a| names.contains(a)) {
|
||||
d.variables.active_dataset = None;
|
||||
}
|
||||
Ok(before - d.variables.datasets.len())
|
||||
})?;
|
||||
Ok(json!({"deleted": n}))
|
||||
}
|
||||
|
||||
fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let current = st.doc.captured_values();
|
||||
let datasets: Vec<Value> = st
|
||||
.doc
|
||||
.variables
|
||||
.datasets
|
||||
.iter()
|
||||
// `matches` says whether the art still holds what the row says, which is what the
|
||||
// panel shows in italics.
|
||||
.map(|d| json!({"name": d.name, "values": d.values, "matches": d.matches(¤t)}))
|
||||
.collect();
|
||||
Ok(json!({"datasets": datasets, "active": st.doc.variables.active_dataset}))
|
||||
}
|
||||
|
||||
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
const C: &str = "dataset.select";
|
||||
let (applied, skipped) = s.edit("Apply Data Set", |d, _| {
|
||||
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
// One object may be driven by several variables; walk them in definition order, so two
|
||||
// text variables on the same object land the later one's value and both are counted.
|
||||
let mut pairs: Vec<(NodeId, String)> = Vec::new();
|
||||
for v in &d.variables.variables {
|
||||
pairs.extend(d.variables.objects_of(&v.name).into_iter().map(|id| (id, v.name.clone())));
|
||||
}
|
||||
let mut applied = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
for (id, var) in pairs {
|
||||
// A binding whose variable is gone, whose value this row doesn't carry, or whose
|
||||
// value doesn't fit the variable's kind, is left alone and counted, never
|
||||
// silently reinterpreted.
|
||||
let (kind, value) = match (d.variables.variable(&var).map(|v| v.kind), ds.values.get(&var)) {
|
||||
(Some(kind), Some(value)) if value.fits(kind) => (kind, value.clone()),
|
||||
_ => {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Locked art, art on a locked or hidden layer, and text whose object is gone, can't
|
||||
// be rewritten: skipped and counted, like the rest. A hidden object is rewritten: a
|
||||
// row that hid it shows it again.
|
||||
if !d.variable_target(id) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let done = match (kind, &value) {
|
||||
// The Type tool's own whole-range replace, so a dataset's text is styled and
|
||||
// laid out exactly as typing it would be.
|
||||
(VariableKind::Text, DataValue::Text(text)) => set_plain_text(d, id, text).is_ok(),
|
||||
(VariableKind::Visibility, DataValue::Visible(show)) => d.node_mut(id).is_some_and(|n| {
|
||||
n.visible = *show;
|
||||
true
|
||||
}),
|
||||
_ => false,
|
||||
};
|
||||
if done {
|
||||
applied += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
d.variables.active_dataset = Some(name.to_string());
|
||||
Ok((applied, skipped))
|
||||
})?;
|
||||
Ok(json!({"name": name, "applied": applied, "skipped": skipped}))
|
||||
}
|
||||
|
||||
fn dataset_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
apply_dataset(s, &var_name(p, "dataset.select")?)
|
||||
}
|
||||
|
||||
fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.variables.datasets.len();
|
||||
if n == 0 {
|
||||
return Err(bad(cmd, "no datasets"));
|
||||
}
|
||||
let at = st.doc.variables.active_dataset.as_deref().and_then(|a| st.doc.variables.datasets.iter().position(|d| d.name == a));
|
||||
let next = match (at, forward) {
|
||||
(Some(i), true) => (i + 1) % n,
|
||||
(Some(i), false) => (i + n - 1) % n,
|
||||
(None, true) => 0,
|
||||
(None, false) => n - 1,
|
||||
};
|
||||
let name = st.doc.variables.datasets.get(next).map(|d| d.name.clone()).ok_or_else(|| bad(cmd, "no datasets"))?;
|
||||
apply_dataset(s, &name)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("variable.define", "New Variable…", ["Window", "Variables"], None, "{name, kind: text|visibility} define a variable", has_doc, define),
|
||||
cmd!(
|
||||
"variable.delete",
|
||||
"Delete Variable",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{names?} delete variables with their bindings and values (default: the highlighted one)",
|
||||
has_doc,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"variable.rename",
|
||||
"Rename Variable",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name, newName} rename a variable, with its bindings and its values in every dataset",
|
||||
has_doc,
|
||||
rename
|
||||
),
|
||||
cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list),
|
||||
cmd!(
|
||||
"variable.makeTextDynamic",
|
||||
"Make Text Dynamic",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{} define a text variable over the selection, named after it and bound to it",
|
||||
has_selection,
|
||||
|s, _| make_dynamic(s, VariableKind::Text, "Text")
|
||||
),
|
||||
cmd!(
|
||||
"variable.makeVisibilityDynamic",
|
||||
"Make Visibility Dynamic",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{} define a visibility variable over the selection, named after it and bound to it",
|
||||
has_selection,
|
||||
|s, _| make_dynamic(s, VariableKind::Visibility, "Visibility")
|
||||
),
|
||||
cmd!(
|
||||
"variable.bind",
|
||||
"Bind Variable",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}",
|
||||
has_doc,
|
||||
bind
|
||||
),
|
||||
cmd!(
|
||||
"variable.unbind",
|
||||
"Unbind Variable",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{ids?, variable?} drop bindings (default: the selection): `variable` drops that one, else every variable the object has",
|
||||
has_doc,
|
||||
unbind
|
||||
),
|
||||
cmd!(
|
||||
"variable.highlight",
|
||||
"Highlight Variable",
|
||||
[],
|
||||
None,
|
||||
"{variable?} highlight one Variables panel row (a click); what the panel's Delete, Options… and Select Bound Object act on. An empty or missing `variable` clears it. Not an undo step → {variable}",
|
||||
has_doc,
|
||||
highlight
|
||||
),
|
||||
cmd!("dataset.new", "New Data Set…", ["Window", "Variables"], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new),
|
||||
cmd!(
|
||||
"dataset.set",
|
||||
"Edit Data Set…",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name, values?: {variable: value}} replace a dataset's values; a variable left out loses its value in this row → {values, removed}",
|
||||
has_doc,
|
||||
dataset_set
|
||||
),
|
||||
cmd!("dataset.rename", "Rename Data Set", ["Window", "Variables"], None, "{name, newName} rename a dataset", has_doc, dataset_rename),
|
||||
cmd!(
|
||||
"dataset.capture",
|
||||
"Capture Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name?} record what the bound objects hold right now as a new dataset (Data Set 1…), and make it the active one",
|
||||
has_doc,
|
||||
dataset_capture
|
||||
),
|
||||
cmd!(
|
||||
"dataset.update",
|
||||
"Update Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{} write what the bound objects hold now into the active dataset",
|
||||
has_doc,
|
||||
dataset_update
|
||||
),
|
||||
cmd!(
|
||||
"dataset.delete",
|
||||
"Delete Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{names?} delete datasets (default: the active one)",
|
||||
has_doc,
|
||||
dataset_delete
|
||||
),
|
||||
cmd!(
|
||||
query "dataset.list",
|
||||
"List Data Sets",
|
||||
[],
|
||||
None,
|
||||
"{} → datasets with their values, the active one, and which rows the art still matches",
|
||||
has_doc,
|
||||
dataset_list
|
||||
),
|
||||
cmd!(
|
||||
"dataset.select",
|
||||
"Select Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name} apply a dataset in one undo step → {applied, skipped}",
|
||||
has_doc,
|
||||
dataset_select
|
||||
),
|
||||
cmd!("dataset.next", "Next Data Set", ["Window", "Variables"], None, "{} apply the next dataset, wrapping", has_doc, |s, _| dataset_step(
|
||||
s,
|
||||
"dataset.next",
|
||||
true
|
||||
)),
|
||||
cmd!("dataset.prev", "Previous Data Set", ["Window", "Variables"], None, "{} apply the previous dataset, wrapping", has_doc, |s, _| {
|
||||
dataset_step(s, "dataset.prev", false)
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod cmd;
|
||||
pub mod file_access;
|
||||
pub mod guard;
|
||||
pub mod inspect;
|
||||
pub mod link_watch;
|
||||
pub mod steps;
|
||||
mod tooling;
|
||||
pub mod units;
|
||||
@@ -85,6 +86,7 @@ pub struct Interaction {
|
||||
/// isolation).
|
||||
pub active_layer: Option<NodeId>,
|
||||
pub layer_rows: Vec<NodeId>,
|
||||
pub variables_highlight: Option<String>,
|
||||
pub isolation: Option<NodeId>,
|
||||
/// The perspective transform the previews make (`perspective.transform` params): Transform
|
||||
/// Again repeats it once the drag is committed.
|
||||
@@ -120,6 +122,10 @@ pub struct DocState {
|
||||
/// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`,
|
||||
/// `layer.highlight`).
|
||||
pub layer_rows: Vec<NodeId>,
|
||||
/// The variable highlighted in the Variables panel: what its Delete, Options… and Select
|
||||
/// Bound Object act on. Panel state, not art selection: not saved, not undoable
|
||||
/// (`variable.highlight`). A name, so renaming a variable moves the highlight with it.
|
||||
pub variables_highlight: Option<String>,
|
||||
/// Isolation mode container.
|
||||
pub isolation: Option<NodeId>,
|
||||
pub interaction: Option<Interaction>,
|
||||
@@ -235,6 +241,7 @@ impl DocState {
|
||||
revision: 1,
|
||||
active_layer,
|
||||
layer_rows: vec![],
|
||||
variables_highlight: None,
|
||||
isolation: None,
|
||||
interaction: None,
|
||||
undo_group: None,
|
||||
@@ -299,6 +306,7 @@ impl DocState {
|
||||
self.selection = it.selection;
|
||||
self.active_layer = it.active_layer;
|
||||
self.layer_rows = it.layer_rows;
|
||||
self.variables_highlight = it.variables_highlight;
|
||||
self.isolation = it.isolation;
|
||||
self.revision += 1;
|
||||
}
|
||||
@@ -511,6 +519,10 @@ pub struct Prefs {
|
||||
/// UI language: `auto` (follow the system locale) or a language code such as `en`, `zh-hant`.
|
||||
/// The list of languages belongs to the shell (`ui-egui` i18n); an unknown code reads as `auto`.
|
||||
pub interface_language: String,
|
||||
/// Windows and Linux: use the system's title bar and window buttons instead of the app bar
|
||||
/// acting as the title bar (tiling window managers, desktops that draw their own decorations).
|
||||
/// Read when the app starts. macOS always uses the system's.
|
||||
pub system_title_bar: bool,
|
||||
// Performance
|
||||
pub gpu_performance: bool,
|
||||
pub animated_zoom: bool,
|
||||
@@ -725,6 +737,7 @@ impl Default for Prefs {
|
||||
ui_scaling: 1.0,
|
||||
scale_cursor_with_ui: false,
|
||||
interface_language: s("auto"),
|
||||
system_title_bar: false,
|
||||
gpu_performance: true,
|
||||
animated_zoom: true,
|
||||
gpu_preference: s("automatic"),
|
||||
@@ -873,6 +886,11 @@ pub struct Session {
|
||||
/// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set
|
||||
/// it.
|
||||
pub search_threads: Option<usize>,
|
||||
/// Each open document's linked files' size and modification time when last seen (linked
|
||||
/// files changing while the document is open, [`link_watch`]).
|
||||
pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>,
|
||||
/// The stamps being taken on a worker thread ([`Session::start_link_scan`]).
|
||||
pub link_scan: Option<link_watch::LinkScan>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -928,6 +946,8 @@ impl Session {
|
||||
font_search: None,
|
||||
search_rules: None,
|
||||
search_threads: None,
|
||||
link_stamps: Default::default(),
|
||||
link_scan: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1213,6 +1233,10 @@ impl Session {
|
||||
if !st.doc.assets.is_empty() {
|
||||
Arc::make_mut(&mut st.doc).prune_assets();
|
||||
}
|
||||
// Variables: bindings let go of deleted art.
|
||||
if !st.doc.variables.bindings.is_empty() {
|
||||
Arc::make_mut(&mut st.doc).prune_variable_bindings();
|
||||
}
|
||||
if doc_sane(&st.doc, &st.selection) {
|
||||
Ok(v)
|
||||
} else {
|
||||
@@ -1277,6 +1301,7 @@ impl Session {
|
||||
preview: None,
|
||||
active_layer: st.active_layer,
|
||||
layer_rows: st.layer_rows.clone(),
|
||||
variables_highlight: st.variables_highlight.clone(),
|
||||
isolation: st.isolation,
|
||||
perspective_again: None,
|
||||
});
|
||||
@@ -1506,6 +1531,8 @@ mod tests_links;
|
||||
#[cfg(test)]
|
||||
mod tests_linkspanel;
|
||||
#[cfg(test)]
|
||||
mod tests_linkwatch;
|
||||
#[cfg(test)]
|
||||
mod tests_liquify;
|
||||
#[cfg(test)]
|
||||
mod tests_live;
|
||||
@@ -1648,6 +1675,8 @@ mod tests_typearea;
|
||||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
#[cfg(test)]
|
||||
mod tests_variables;
|
||||
#[cfg(test)]
|
||||
mod tests_webexport;
|
||||
#[cfg(test)]
|
||||
mod tests_widthpoints;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Linked files that change while their document is open (another app saved them: PhotoCraft,
|
||||
//! LightCraft, Photoshop, a placed VectorCraft document): Preferences › File Handling › Update
|
||||
//! Links decides, as it does when a document opens. Automatically reads them again
|
||||
//! (`links.update`, an undo step), Ask When Modified offers to, Manually leaves them modified in
|
||||
//! the Links panel.
|
||||
//!
|
||||
//! The active document's linked files are compared by size and modification time with what they
|
||||
//! were when last seen. The desktop app takes the stamps every two seconds and when its window
|
||||
//! comes back to the front, on a worker thread so a file on a slow network share never holds up
|
||||
//! the interface ([`Session::start_link_scan`], [`Session::poll_link_scan`]);
|
||||
//! `links.updateChanged` takes them on the spot and reads the changed files again (CLI, agents).
|
||||
//!
|
||||
//! Stamps are kept per document: one in the background is looked at when it comes to the front.
|
||||
//! A file seen for the first time is only remembered (one modified while its document was closed
|
||||
//! is handled when the document opens); one that can't be read yet (another app is still writing
|
||||
//! it) keeps its old stamp, so the next look tries again. A change is acted on once: undoing an
|
||||
//! update, or answering No, leaves the link modified until the file changes again.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// A file's size and modification time (nanoseconds since 1970).
|
||||
pub type Stamp = (u64, u128);
|
||||
|
||||
/// Stamps of a document's linked files taken on a worker thread: (document uid, [(file,
|
||||
/// object ids, stamp)]).
|
||||
pub type LinkScan = Arc<Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>>;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn stamp(path: &str) -> Option<Stamp> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
Some((meta.len(), modified.as_nanos()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn stamp(_: &str) -> Option<Stamp> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The linked files of `d` with the ids of the objects showing them.
|
||||
pub fn linked_files(d: &vectorcraft_doc::Document) -> Vec<(String, Vec<u64>)> {
|
||||
crate::cmd::links::linked_groups(d)
|
||||
}
|
||||
|
||||
/// `links.updateChanged`: stamp the active document's linked files now and read the changed
|
||||
/// ones again, whatever Update Links says.
|
||||
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
|
||||
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
|
||||
s.apply_link_stamps(uid, now, true)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Start stamping the active document's linked files on a worker thread, unless a look is
|
||||
/// still running (or the build has no files to look at: the web app).
|
||||
pub fn start_link_scan(&mut self) {
|
||||
if self.link_scan.is_some() || cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
let Some(st) = self.active() else { return };
|
||||
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let slot: LinkScan = Arc::default();
|
||||
let out = slot.clone();
|
||||
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
|
||||
let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
|
||||
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps));
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
self.link_scan = Some(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a finished look (`None` while there is none, or it is still running): see
|
||||
/// [`Session::apply_link_stamps`].
|
||||
pub fn poll_link_scan(&mut self) -> Option<Result<Value>> {
|
||||
let (uid, stamps) = self.link_scan.as_ref()?.lock().unwrap_or_else(PoisonError::into_inner).take()?;
|
||||
self.link_scan = None;
|
||||
Some(self.apply_link_stamps(uid, stamps, false))
|
||||
}
|
||||
|
||||
/// Compare the stamps of document `uid`'s linked files with the ones last seen and act on
|
||||
/// the changed ones as Update Links says (`update`: read them again regardless). Returns
|
||||
/// `{updated: [ids], ask: [ids], modified: [ids]}`: read again, to offer (Ask When Modified),
|
||||
/// or left modified (Manually).
|
||||
pub fn apply_link_stamps(&mut self, uid: u64, now: Vec<(String, Vec<u64>, Option<Stamp>)>, update: bool) -> Result<Value> {
|
||||
let none = || json!({"updated": [], "ask": [], "modified": []});
|
||||
// The document went to the back meanwhile: it's looked at when it's in front again.
|
||||
if self.active().map(|d| d.uid) != Some(uid) {
|
||||
return Ok(none());
|
||||
}
|
||||
// This document's last stamps; those of closed documents are dropped.
|
||||
let open: HashSet<u64> = self.documents().iter().map(|d| d.uid).collect();
|
||||
let mut before: HashMap<String, Stamp> = HashMap::new();
|
||||
self.link_stamps.retain(|(u, p), s| {
|
||||
if *u == uid {
|
||||
before.insert(p.clone(), *s);
|
||||
false
|
||||
} else {
|
||||
open.contains(u)
|
||||
}
|
||||
});
|
||||
let mut changed: Vec<(String, Vec<u64>)> = Vec::new();
|
||||
for (path, ids, stamp) in now {
|
||||
match (before.get(&path), stamp) {
|
||||
// Not there right now (an app saving by delete and rename): keep the last stamp.
|
||||
(Some(old), None) => {
|
||||
self.link_stamps.insert((uid, path), *old);
|
||||
}
|
||||
(old, Some(new)) => {
|
||||
if old.is_some_and(|o| *o != new) {
|
||||
changed.push((path.clone(), ids));
|
||||
}
|
||||
self.link_stamps.insert((uid, path), new);
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Ok(none());
|
||||
}
|
||||
let ids: Vec<u64> = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).collect();
|
||||
let mode = if update { "automatically" } else { self.prefs.update_links.as_str() };
|
||||
match mode {
|
||||
"automatically" => {
|
||||
let r = self.execute("links.update", &json!({"ids": ids}))?;
|
||||
// Not readable yet (still being written): the next look tries again.
|
||||
let unread: BTreeSet<u64> = r["missing"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
for (path, ids) in &changed {
|
||||
if ids.iter().any(|i| unread.contains(i))
|
||||
&& let Some(old) = before.get(path)
|
||||
{
|
||||
self.link_stamps.insert((uid, path.clone()), *old);
|
||||
}
|
||||
}
|
||||
Ok(json!({"updated": r["updated"], "ask": [], "modified": []}))
|
||||
}
|
||||
"askWhenModified" => Ok(json!({"updated": [], "ask": ids, "modified": []})),
|
||||
_ => Ok(json!({"updated": [], "ask": [], "modified": ids})),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Linked files changing while their document is open (`link_watch`): `links.updateChanged`,
|
||||
//! and the background look following Preferences → File Handling → Update Links.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::tests_links::{BLUE, Folder, RED, centre_colour, place, png, session};
|
||||
|
||||
/// Write `bytes` to `path` so its modification time moves even on coarse file systems.
|
||||
fn save(path: &str, bytes: &[u8]) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn colour(s: &Session) -> [u8; 3] {
|
||||
centre_colour(&s.doc().unwrap().doc)
|
||||
}
|
||||
|
||||
/// One background look, waited for.
|
||||
fn look(s: &mut Session) -> Value {
|
||||
s.start_link_scan();
|
||||
let t0 = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(r) = s.poll_link_scan() {
|
||||
return r.unwrap();
|
||||
}
|
||||
assert!(t0.elapsed().as_secs() < 10, "the look never finished");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_changed_reads_changed_files_again_once() {
|
||||
let dir = Folder::new("watch-update");
|
||||
let path = dir.file("art.png");
|
||||
std::fs::write(&path, png(40, 40, RED)).unwrap();
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &path);
|
||||
// First look: remembered, nothing to read.
|
||||
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
|
||||
save(&path, &png(40, 40, BLUE));
|
||||
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([id.0]));
|
||||
assert_eq!(colour(&s), BLUE);
|
||||
// Undoing it is respected: the link stays modified until the file changes again.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(colour(&s), RED);
|
||||
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
|
||||
assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_look_follows_update_links() {
|
||||
for (mode, key) in [("automatically", "updated"), ("askWhenModified", "ask"), ("manually", "modified")] {
|
||||
let dir = Folder::new(&format!("watch-{mode}"));
|
||||
let path = dir.file("art.png");
|
||||
std::fs::write(&path, png(40, 40, RED)).unwrap();
|
||||
let mut s = session();
|
||||
s.prefs.update_links = mode.into();
|
||||
let id = place(&mut s, &path);
|
||||
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}), "{mode}");
|
||||
save(&path, &png(40, 40, BLUE));
|
||||
let r = look(&mut s);
|
||||
assert_eq!(r[key], json!([id.0]), "{mode}: {r}");
|
||||
assert_eq!(colour(&s), if mode == "automatically" { BLUE } else { RED }, "{mode}");
|
||||
// Acted on once: the next look finds nothing new.
|
||||
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}), "{mode}");
|
||||
assert!(s.link_scan.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_still_being_written_is_tried_again() {
|
||||
let dir = Folder::new("watch-partial");
|
||||
let path = dir.file("art.png");
|
||||
std::fs::write(&path, png(40, 40, RED)).unwrap();
|
||||
let mut s = session();
|
||||
s.prefs.update_links = "automatically".into();
|
||||
let id = place(&mut s, &path);
|
||||
look(&mut s);
|
||||
// Another app has only written the start of the file so far.
|
||||
let full = png(40, 40, BLUE);
|
||||
save(&path, &full[..16]);
|
||||
assert_eq!(look(&mut s)["updated"], json!([]));
|
||||
assert_eq!(colour(&s), RED, "the last good pixels stay");
|
||||
save(&path, &full);
|
||||
assert_eq!(look(&mut s)["updated"], json!([id.0]));
|
||||
assert_eq!(colour(&s), BLUE);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate ›
|
||||
//! Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture ›
|
||||
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes,
|
||||
//! Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture ›
|
||||
//! Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, Video): applied and
|
||||
//! edited as commands, listed in the catalogue, drawn on the canvas, and written to PDF and SVG
|
||||
//! as images of the effected object.
|
||||
@@ -18,6 +18,23 @@ const EFFECTS: [(&str, &str); 3] = [
|
||||
("sharpen.unsharpMask", r#"{"amount": 300, "radius": 3}"#),
|
||||
];
|
||||
|
||||
/// Effect › Brush Strokes, at their defaults.
|
||||
const BRUSH_STROKES: [(&str, &str); 8] = [
|
||||
("brushStrokes.accentedEdges", "{}"),
|
||||
("brushStrokes.angledStrokes", "{}"),
|
||||
("brushStrokes.crosshatch", "{}"),
|
||||
("brushStrokes.darkStrokes", "{}"),
|
||||
("brushStrokes.inkOutlines", "{}"),
|
||||
("brushStrokes.spatter", "{}"),
|
||||
("brushStrokes.sprayedStrokes", "{}"),
|
||||
("brushStrokes.sumiE", "{}"),
|
||||
];
|
||||
|
||||
/// Effect › Distort (Diffuse Glow with its highlights glowing from a low brightness up, as the
|
||||
/// striped art's reds are dark).
|
||||
const DISTORT: [(&str, &str); 3] =
|
||||
[("distort.diffuseGlow", r#"{"clearAmount": 0, "glowAmount": 20}"#), ("distort.glass", "{}"), ("distort.oceanRipple", "{}")];
|
||||
|
||||
/// Effect › Pixelate, at their defaults.
|
||||
const PIXELATE: [(&str, &str); 4] =
|
||||
[("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")];
|
||||
@@ -73,6 +90,8 @@ fn listed_applied_edited_and_undone() {
|
||||
EFFECTS.iter().map(|(id, _)| list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap()["menu"].clone()).collect();
|
||||
assert_eq!(menus, [json!(["Effect", "Blur"]), json!(["Effect", "Blur"]), json!(["Effect", "Sharpen"])]);
|
||||
for (group, menu) in [
|
||||
(BRUSH_STROKES.as_slice(), "Brush Strokes"),
|
||||
(DISTORT.as_slice(), "Distort"),
|
||||
(PIXELATE.as_slice(), "Pixelate"),
|
||||
(TEXTURE.as_slice(), "Texture"),
|
||||
(VIDEO.as_slice(), "Video"),
|
||||
@@ -102,7 +121,7 @@ fn listed_applied_edited_and_undone() {
|
||||
|
||||
#[test]
|
||||
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
|
||||
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
|
||||
for (id, params) in EFFECTS.into_iter().chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
|
||||
let (mut s, g) = striped();
|
||||
let plain = render(&s);
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
@@ -131,7 +150,7 @@ fn spin_blur_reaches_past_the_corners() {
|
||||
|
||||
#[test]
|
||||
fn pdf_and_svg_write_the_effected_object_as_an_image() {
|
||||
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
|
||||
for (id, params) in EFFECTS.into_iter().chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
|
||||
let (mut s, g) = striped();
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
//! Variables (data merge): definitions with kinds, bindings to objects, datasets
|
||||
//! applied in one undo step, and validation that rejects instead of reinterpreting.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, content: &str) -> NodeId {
|
||||
let r = s.execute("text.create", &json!({"x": 10, "y": 10, "text": content})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session) -> NodeId {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn plain(s: &Session, id: NodeId) -> String {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_visibility_datasets_apply_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "B", "values": {"Name": "Bob", "Show": false}})).unwrap();
|
||||
|
||||
let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0)));
|
||||
assert_eq!(plain(&s, t), "Bob");
|
||||
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
|
||||
|
||||
// One undo step covers the whole dataset application.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "Alice");
|
||||
assert!(s.doc().unwrap().doc.node(r).unwrap().visible);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "Bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dataset_next_and_prev_wrap_around() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
for name in ["A", "B"] {
|
||||
s.execute("dataset.new", &json!({"name": name, "values": {"Name": name}})).unwrap();
|
||||
}
|
||||
s.execute("dataset.next", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "A");
|
||||
s.execute("dataset.next", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "B");
|
||||
s.execute("dataset.next", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "A");
|
||||
s.execute("dataset.prev", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "B");
|
||||
let v = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(v["active"], "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_and_missing_bindings_are_skipped_and_counted() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
// A text variable bound to a rectangle applies to nothing; a dataset value for an
|
||||
// unbound variable is ignored.
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Ghost": "z"}})).unwrap_err();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y"}})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [r.0]})).unwrap_err();
|
||||
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0)));
|
||||
assert_eq!(plain(&s, t), "y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_variable_cleans_bindings_values_and_leaves_datasets_alone() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y"}})).unwrap();
|
||||
s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
s.execute("variable.delete", &json!({"names": ["Name"]})).unwrap();
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(vars["variables"].as_array().map(Vec::len), Some(0));
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert!(sets["datasets"][0].get("values").is_none_or(|v| v == &json!({})));
|
||||
// The dataset named "D" is still the active one: `variable.delete` prunes variables, and
|
||||
// the dataset's own name is its own namespace.
|
||||
assert_eq!(sets["active"], "D");
|
||||
s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(0));
|
||||
assert!(sets["active"].is_null());
|
||||
let v: Value = serde_json::from_str(&serde_json::to_string(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({})));
|
||||
}
|
||||
|
||||
/// A dataset may share a variable's name: they are separate namespaces, and deleting the
|
||||
/// variable must not silently unselect the dataset that happens to be called the same.
|
||||
#[test]
|
||||
fn a_dataset_survives_a_variable_of_the_same_name_being_deleted() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "Row", "kind": "text"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Row", "values": {"Row": "y"}})).unwrap();
|
||||
s.execute("dataset.select", &json!({"name": "Row"})).unwrap();
|
||||
s.execute("variable.delete", &json!({"names": ["Row"]})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["name"], "Row", "the dataset is not a variable");
|
||||
assert_eq!(sets["active"], "Row", "the active dataset keeps its name");
|
||||
}
|
||||
|
||||
/// Deleting art lets its bindings go, as it does for Asset Export's assets: a binding that
|
||||
/// outlived its object would count as a skip in every dataset application and grow the file.
|
||||
#[test]
|
||||
fn deleted_art_lets_its_bindings_go() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap();
|
||||
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
let of = |name: &str| vars["variables"].as_array().map(|a| a.iter().find(|v| v["name"] == name).unwrap()["bindings"].as_u64().unwrap()).unwrap();
|
||||
assert_eq!(of("Name"), 0, "the deleted text object took its binding with it");
|
||||
assert_eq!(of("Show"), 1, "the rectangle is still bound");
|
||||
// Nothing stale is left for the application to skip.
|
||||
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0)));
|
||||
}
|
||||
|
||||
/// `deleted` counts what was deleted, not what was asked for.
|
||||
#[test]
|
||||
fn delete_reports_what_it_actually_removed() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "A", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "B", "kind": "text"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!(s.execute("variable.delete", &json!({"names": ["A"]})).unwrap()["deleted"], 1);
|
||||
assert_eq!(s.execute("variable.delete", &json!({"names": ["A"]})).unwrap()["deleted"], 0, "already gone");
|
||||
assert_eq!(s.execute("variable.delete", &json!({"names": ["A", "B"]})).unwrap()["deleted"], 1, "only B was left");
|
||||
assert_eq!(s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap()["deleted"], 1);
|
||||
assert_eq!(s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap()["deleted"], 0);
|
||||
}
|
||||
|
||||
/// An object may be driven by several variables, so binding it again adds to what it has
|
||||
/// instead of quietly dropping the earlier one.
|
||||
#[test]
|
||||
fn one_object_can_be_driven_by_several_variables() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Badge", "kind": "visibility"})).unwrap();
|
||||
|
||||
// A text object driven by two text variables keeps both.
|
||||
assert_eq!(s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap()["bound"], 1);
|
||||
let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
|
||||
assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(1), json!([])), "Title is untouched");
|
||||
// A rectangle driven by a visibility variable as well as nothing else yet.
|
||||
s.execute("variable.bind", &json!({"variable": "Badge", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Title": "Hat", "Price": "9.99", "Badge": false}})).unwrap();
|
||||
|
||||
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(3), Some(0)), "every binding applied");
|
||||
// Both text variables are counted; the later one in definition order is what is left.
|
||||
assert_eq!(plain(&s, t), "9.99");
|
||||
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
|
||||
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
let of = |n: &str| vars["variables"].as_array().unwrap().iter().find(|v| v["name"] == n).unwrap()["bindings"].as_u64();
|
||||
assert_eq!((of("Title"), of("Price"), of("Badge")), (Some(1), Some(1), Some(1)));
|
||||
|
||||
// Binding again to one it already has is not a change, and says so.
|
||||
let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
|
||||
assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(0), json!([t.0])));
|
||||
// Unbinding one variable leaves the object's others alone.
|
||||
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Price"})).unwrap()["unbound"], 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(vars["variables"][1]["bindings"], 1, "the other binding is still there");
|
||||
assert!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Ghost"})).is_err());
|
||||
|
||||
// Deleting a variable drops only its own binding.
|
||||
s.execute("variable.delete", &json!({"names": ["Price"]})).unwrap();
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(vars["variables"][0]["bindings"], 1, "Title still drives the text");
|
||||
}
|
||||
|
||||
/// `unbound` counts what was actually dropped, like `variable.delete` does.
|
||||
#[test]
|
||||
fn unbind_reports_what_it_actually_removed() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
|
||||
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap()["unbound"], 1);
|
||||
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap()["unbound"], 0, "already gone");
|
||||
// Without `variable` the object's last binding goes and the entry with it.
|
||||
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0]})).unwrap()["unbound"], 1);
|
||||
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0]})).unwrap()["unbound"], 0);
|
||||
// An object with no binding left is not kept as an empty set.
|
||||
let v: Value = serde_json::from_str(&serde_json::to_string(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({})), "{v}");
|
||||
}
|
||||
|
||||
/// Locked art is the document's to rewrite only while it isn't locked.
|
||||
#[test]
|
||||
fn locked_art_is_neither_bound_nor_rewritten() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap();
|
||||
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
assert!(s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).is_err(), "locked art takes no new binding");
|
||||
// The bindings made before the lock still count, and the application skips the locked art.
|
||||
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(1)));
|
||||
assert_eq!(plain(&s, t), "x", "the locked text was left alone");
|
||||
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible, "the unlocked rectangle still applied");
|
||||
}
|
||||
|
||||
/// Make Text Dynamic / Make Visibility Dynamic: what the panel's buttons run. One step from a
|
||||
/// selection to a bound variable, named after what it drives.
|
||||
#[test]
|
||||
fn making_a_selection_dynamic_names_and_binds_it() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
let r = rect(&mut s);
|
||||
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
let v = s.execute("variable.makeTextDynamic", &json!({})).unwrap();
|
||||
assert_eq!(v["name"], "Alice", "named after the text it drives");
|
||||
assert_eq!(v["ids"], json!([t.0]));
|
||||
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let v = s.execute("variable.makeVisibilityDynamic", &json!({})).unwrap();
|
||||
assert_eq!(v["kind"], "visibility");
|
||||
|
||||
// The same object twice takes a number rather than colliding with the first variable.
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
let again = s.execute("variable.makeTextDynamic", &json!({})).unwrap();
|
||||
assert_eq!(again["name"], "Alice 2");
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(vars["variables"].as_array().map(Vec::len), Some(3));
|
||||
|
||||
// Nothing selected, art that can't take the kind, and locked art are errors.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("variable.makeTextDynamic", &json!({})).is_err());
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
assert!(s.execute("variable.makeTextDynamic", &json!({})).is_err(), "a rectangle is not type");
|
||||
}
|
||||
|
||||
/// Capture Data Set records what the art holds now, which is how rows are made — the art is
|
||||
/// edited and captured, rather than the values typed.
|
||||
#[test]
|
||||
fn capturing_records_what_the_art_holds_now() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
|
||||
let v = s.execute("dataset.capture", &json!({})).unwrap();
|
||||
assert_eq!((v["name"].clone(), v["values"].clone()), (json!("Data Set 1"), json!(2)));
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["values"]["Name"], json!({"text": "Alice"}));
|
||||
assert_eq!(sets["datasets"][0]["values"]["Show"], json!({"visible": true}));
|
||||
assert_eq!(sets["active"], "Data Set 1");
|
||||
|
||||
// Change the art, capture again: a second row, and the first still matches its own text.
|
||||
s.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap();
|
||||
assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["matches"], json!(false), "the art moved on");
|
||||
s.execute("dataset.capture", &json!({})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][1]["name"], "Data Set 2");
|
||||
assert_eq!(sets["datasets"][1]["values"]["Name"], json!({"text": "Bob"}));
|
||||
assert_eq!(sets["datasets"][0]["matches"], json!(false), "row 1 still describes the old text");
|
||||
|
||||
// A named capture, and a name that is taken is an error rather than a second row.
|
||||
s.execute("dataset.capture", &json!({"name": "Winter"})).unwrap();
|
||||
assert!(s.execute("dataset.capture", &json!({"name": "Winter"})).is_err());
|
||||
|
||||
// Nothing bound is nothing to capture.
|
||||
let mut empty = session();
|
||||
empty.execute("variable.define", &json!({"name": "V", "kind": "text"})).unwrap();
|
||||
assert!(empty.execute("dataset.capture", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Update Data Set writes the art's current values into the row that is active.
|
||||
#[test]
|
||||
fn updating_writes_the_art_into_the_active_row() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
|
||||
// No active row yet: say so rather than quietly writing into some other one.
|
||||
assert!(s.execute("dataset.update", &json!({})).is_err());
|
||||
s.execute("dataset.capture", &json!({})).unwrap();
|
||||
s.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap();
|
||||
let v = s.execute("dataset.update", &json!({})).unwrap();
|
||||
assert_eq!((v["name"].clone(), v["values"].clone()), (json!("Data Set 1"), json!(1)));
|
||||
assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["matches"], json!(true));
|
||||
// One undo step puts the row back as it was.
|
||||
s.execute("text.editRange", &json!({"id": t.0, "insert": "Caz"})).unwrap();
|
||||
s.execute("dataset.update", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "Caz");
|
||||
}
|
||||
|
||||
/// Renaming carries the variable's bindings and its values in every row with it — a name is
|
||||
/// how a variable is referred to, so leaving the old one behind would orphan both.
|
||||
#[test]
|
||||
fn renaming_carries_the_bindings_and_values_with_it() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "Bob"}})).unwrap();
|
||||
s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
|
||||
s.execute("variable.rename", &json!({"name": "Name", "newName": "Title"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "Bob", "the row still applied");
|
||||
assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["values"]["Title"], json!({"text": "Bob"}));
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!((vars["variables"][0]["name"].clone(), vars["variables"][0]["bindings"].clone()), (json!("Title"), json!(1)));
|
||||
|
||||
// Renaming a dataset keeps it active and keeps its values.
|
||||
s.execute("dataset.rename", &json!({"name": "D", "newName": "Row 1"})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!((sets["active"].clone(), sets["datasets"][0]["name"].clone()), (json!("Row 1"), json!("Row 1")));
|
||||
|
||||
// Taken and missing names are errors.
|
||||
s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap();
|
||||
assert!(s.execute("variable.rename", &json!({"name": "Title", "newName": "Other"})).is_err());
|
||||
assert!(s.execute("variable.rename", &json!({"name": "Ghost", "newName": "X"})).is_err());
|
||||
assert!(s.execute("dataset.rename", &json!({"name": "Ghost", "newName": "X"})).is_err());
|
||||
// A dataset and a variable are separate namespaces: a dataset may take a variable's name,
|
||||
// and a variable may take a dataset's.
|
||||
s.execute("dataset.rename", &json!({"name": "Row 1", "newName": "Title"})).unwrap();
|
||||
assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["active"], "Title");
|
||||
s.execute("dataset.new", &json!({"name": "Taken"})).unwrap();
|
||||
assert!(s.execute("dataset.rename", &json!({"name": "Title", "newName": "Taken"})).is_err());
|
||||
}
|
||||
|
||||
/// `dataset.set` is how a value changes after the row exists: replacing it must not need the
|
||||
/// dataset to be deleted and rebuilt (which loses its place in the list and the active one).
|
||||
#[test]
|
||||
fn dataset_set_replaces_the_row_and_reports_what_it_dropped() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap();
|
||||
|
||||
// The dataset keeps its place and stays active while its values change.
|
||||
let v = s.execute("dataset.set", &json!({"name": "D", "values": {"Name": "z"}})).unwrap();
|
||||
assert_eq!((v["values"].clone(), v["removed"].clone()), (json!(1), json!(["Show"])));
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "z"}}));
|
||||
|
||||
s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "z");
|
||||
assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "`Show` has no value in this row now");
|
||||
|
||||
// Errors are errors, not a silent rewrite of some other row.
|
||||
assert!(s.execute("dataset.set", &json!({"name": "Missing"})).is_err());
|
||||
assert!(s.execute("dataset.set", &json!({"name": "D", "values": {"Ghost": "z"}})).is_err());
|
||||
assert!(s.execute("dataset.set", &json!({"name": "D", "values": {"Show": "yes"}})).is_err());
|
||||
assert!(s.execute("dataset.set", &json!({"name": "D", "values": []})).is_err());
|
||||
// One step, so one undo puts the row back as it was.
|
||||
s.execute("dataset.set", &json!({"name": "D", "values": {"Name": "w"}})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_variables_requests_are_errors_not_panics() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "N", "kind": "text"})).unwrap();
|
||||
for (cmd, params) in [
|
||||
("variable.define", json!({})),
|
||||
("variable.define", json!({"name": "", "kind": "text"})),
|
||||
("variable.define", json!({"name": "N", "kind": "colour"})),
|
||||
("variable.define", json!({"name": "N", "kind": "text"})),
|
||||
("variable.delete", json!({})),
|
||||
("variable.bind", json!({})),
|
||||
("variable.bind", json!({"variable": "N"})),
|
||||
("variable.bind", json!({"variable": "Missing", "ids": [1]})),
|
||||
("dataset.new", json!({})),
|
||||
("dataset.new", json!({"name": "D", "values": []})),
|
||||
("dataset.new", json!({"name": "D", "values": {"N": 5}})),
|
||||
("dataset.select", json!({"name": "Missing"})),
|
||||
("dataset.next", json!({})),
|
||||
] {
|
||||
assert!(s.execute(cmd, ¶ms).is_err(), "{cmd} {params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_selects_without_unbinding_and_deletes_leave_the_rest() {
|
||||
// The reviewer's regression case: two variables, two rows.
|
||||
let mut s = session();
|
||||
let a = text(&mut s, "Alice");
|
||||
let b = text(&mut s, "Bob");
|
||||
for (name, id) in [("Name", a), ("Other", b)] {
|
||||
s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": name, "ids": [id.0]})).unwrap();
|
||||
}
|
||||
s.execute("dataset.new", &json!({"name": "One", "values": {"Name": "Alice", "Other": "Bob"}})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Two", "values": {"Name": "A2", "Other": "B2"}})).unwrap();
|
||||
s.execute("dataset.select", &json!({"name": "One"})).unwrap();
|
||||
|
||||
// What a row click runs (highlight), then what Select Bound Object runs (select).
|
||||
s.execute("variable.highlight", &json!({"variable": "Name"})).unwrap();
|
||||
let bound: Vec<u64> = s.doc().unwrap().doc.variables.objects_of("Name").iter().map(|i| i.0).collect();
|
||||
s.execute("select.set", &json!({"ids": bound})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
// …and the binding is still there: selecting never unbinds.
|
||||
assert_eq!(s.doc().unwrap().doc.variables.objects_of("Name"), vec![a]);
|
||||
|
||||
// Deleting one variable leaves the other; deleting the current dataset leaves the other.
|
||||
s.execute("variable.delete", &json!({"names": ["Name"]})).unwrap();
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(vars["variables"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(vars["variables"][0]["name"], "Other");
|
||||
s.execute("dataset.delete", &json!({"names": ["One"]})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(sets["datasets"][0]["name"], "Two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_follows_rename_and_clears_with_delete() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
assert!(s.execute("variable.highlight", &json!({"variable": "Missing"})).is_err(), "unknown names stay errors");
|
||||
let v = s.execute("variable.highlight", &json!({"variable": "Name"})).unwrap();
|
||||
assert_eq!(v["variable"], "Name");
|
||||
// The highlight names a variable, so the rename carries it along.
|
||||
s.execute("variable.rename", &json!({"name": "Name", "newName": "Title"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().variables_highlight.as_deref(), Some("Title"));
|
||||
// Empty clears it, and deleting the highlighted variable clears it too.
|
||||
s.execute("variable.highlight", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().variables_highlight.is_none());
|
||||
s.execute("variable.highlight", &json!({"variable": "Title"})).unwrap();
|
||||
s.execute("variable.delete", &json!({"names": ["Title"]})).unwrap();
|
||||
assert!(s.doc().unwrap().variables_highlight.is_none(), "a highlight of a deleted variable highlights nothing");
|
||||
}
|
||||
|
||||
/// A row that hides an object must be able to show it again, and hiding it on the artboard
|
||||
/// (as the Layers panel does) is what a capture records: a visibility variable reads and
|
||||
/// writes its object's own visibility, which therefore never makes the object off limits.
|
||||
#[test]
|
||||
fn a_hidden_object_comes_back_and_is_captured_hidden() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0, t.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Shown", "values": {"Name": "Alice", "Show": true}})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Hidden", "values": {"Name": "Bob", "Show": false}})).unwrap();
|
||||
|
||||
s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
|
||||
let v = s.execute("dataset.select", &json!({"name": "Shown"})).unwrap();
|
||||
assert_eq!(v["skipped"], json!(0), "{v}");
|
||||
assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "the row shows it again");
|
||||
assert_eq!(plain(&s, t), "Alice", "and rewrites the text of the object it had hidden");
|
||||
|
||||
// Hidden on the artboard, then captured: the row says hidden, and it matches the art.
|
||||
s.execute("select.set", &json!({"ids": [t.0, r.0]})).unwrap();
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
s.execute("dataset.capture", &json!({"name": "Captured"})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
let captured = sets["datasets"].as_array().unwrap().iter().find(|d| d["name"] == "Captured").unwrap().clone();
|
||||
assert_eq!(captured["values"]["Show"], json!({"visible": false}), "{captured}");
|
||||
assert_eq!(captured["matches"], json!(true));
|
||||
|
||||
// A hidden object can still be bound by id.
|
||||
s.execute("variable.define", &json!({"name": "More", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "More", "ids": [r.0]})).unwrap();
|
||||
}
|
||||
|
||||
/// Variables and datasets are separate namespaces: renaming a variable leaves a dataset of
|
||||
/// the same name, and which dataset is active, alone.
|
||||
#[test]
|
||||
fn renaming_a_variable_leaves_a_dataset_of_the_same_name_active() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.capture", &json!({"name": "Title"})).unwrap();
|
||||
s.execute("variable.rename", &json!({"name": "Title", "newName": "Heading"})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!((sets["active"].clone(), sets["datasets"][0]["name"].clone()), (json!("Title"), json!("Title")));
|
||||
s.execute("dataset.update", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// Without `names`, the deletes act on what the panel shows as current: the highlighted
|
||||
/// variable and the active dataset (Window › Variables runs them with no params).
|
||||
#[test]
|
||||
fn deletes_without_names_take_the_highlight_and_the_active_dataset() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
for name in ["A", "B"] {
|
||||
s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
|
||||
}
|
||||
s.execute("variable.bind", &json!({"variable": "A", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.capture", &json!({})).unwrap();
|
||||
s.execute("dataset.capture", &json!({})).unwrap();
|
||||
assert!(s.execute("variable.delete", &json!({})).is_err(), "nothing highlighted");
|
||||
|
||||
s.execute("variable.highlight", &json!({"variable": "B"})).unwrap();
|
||||
assert_eq!(s.execute("variable.delete", &json!({})).unwrap()["deleted"], json!(1));
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(vars["variables"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(vars["variables"][0]["name"], "A");
|
||||
|
||||
assert_eq!(s.execute("dataset.delete", &json!({})).unwrap()["deleted"], json!(1));
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(sets["datasets"][0]["name"], "Data Set 1");
|
||||
assert!(s.execute("dataset.delete", &json!({})).is_err(), "no active dataset left");
|
||||
}
|
||||
|
||||
/// Ids and names from an agent are checked, never reinterpreted: an `ids` that isn't a list
|
||||
/// of objects is an error rather than the selection, and names are trimmed and capped.
|
||||
#[test]
|
||||
fn ids_and_names_from_an_agent_are_checked() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
s.execute("variable.define", &json!({"name": " Name ", "kind": "text"})).unwrap();
|
||||
assert_eq!(s.execute("variable.list", &json!({})).unwrap()["variables"][0]["name"], "Name");
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
for params in
|
||||
[json!({"variable": "Name", "ids": 5}), json!({"variable": "Name", "ids": [t.0, "x"]}), json!({"variable": "Name", "ids": [999_999]})]
|
||||
{
|
||||
assert!(s.execute("variable.bind", ¶ms).is_err(), "{params}");
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.variables.objects_of("Name").is_empty(), "nothing was bound");
|
||||
assert!(s.execute("variable.unbind", &json!({"ids": "all"})).is_err());
|
||||
assert!(s.execute("variable.define", &json!({"name": "x".repeat(256), "kind": "text"})).is_err());
|
||||
s.execute("variable.define", &json!({"name": "x".repeat(255), "kind": "text"})).unwrap();
|
||||
assert!(s.execute("variable.delete", &json!({"names": []})).is_err());
|
||||
assert!(s.execute("variable.delete", &json!({"names": [5]})).is_err());
|
||||
}
|
||||
@@ -292,6 +292,26 @@ fn structured_junk() {
|
||||
("effect.apply", json!({"effect": "blur.radial", "params": {"amount": 1e308, "method": 5, "quality": "best"}})),
|
||||
("effect.apply", json!({"effect": "blur.smart", "params": {"radius": 1e308, "threshold": -1e308, "quality": null}})),
|
||||
("effect.apply", json!({"effect": "sharpen.unsharpMask", "params": {"amount": -1, "radius": "1e999", "threshold": 1e308}})),
|
||||
(
|
||||
"effect.apply",
|
||||
json!({"effect": "brushStrokes.accentedEdges", "params": {"edgeWidth": 1e308, "edgeBrightness": "NaN", "smoothness": -1e308}}),
|
||||
),
|
||||
(
|
||||
"effect.apply",
|
||||
json!({"effect": "brushStrokes.angledStrokes", "params": {"directionBalance": [1], "strokeLength": 1e308, "sharpness": null}}),
|
||||
),
|
||||
("effect.apply", json!({"effect": "brushStrokes.crosshatch", "params": {"strokeLength": "1e999", "sharpness": 1e308, "strength": -1e308}})),
|
||||
("effect.apply", json!({"effect": "brushStrokes.darkStrokes", "params": {"balance": -1e308, "blackIntensity": {}, "whiteIntensity": 1e308}})),
|
||||
(
|
||||
"effect.apply",
|
||||
json!({"effect": "brushStrokes.inkOutlines", "params": {"strokeLength": -1, "darkIntensity": 1e308, "lightIntensity": "x"}}),
|
||||
),
|
||||
("effect.apply", json!({"effect": "brushStrokes.spatter", "params": {"sprayRadius": 1e308, "smoothness": "NaN"}})),
|
||||
(
|
||||
"effect.apply",
|
||||
json!({"effect": "brushStrokes.sprayedStrokes", "params": {"strokeLength": 1e308, "sprayRadius": -1e308, "strokeDirection": 3}}),
|
||||
),
|
||||
("effect.apply", json!({"effect": "brushStrokes.sumiE", "params": {"strokeWidth": 0, "strokePressure": 1e308, "contrast": [1]}})),
|
||||
("effect.apply", json!({"effect": "pixelate.colorHalftone", "params": {"maxRadius": 1e308, "channel1": "1e999", "channel4": null}})),
|
||||
("effect.apply", json!({"effect": "pixelate.crystallize", "params": {"cellSize": -1e308}})),
|
||||
("effect.apply", json!({"effect": "pixelate.mezzotint", "params": {"type": 5}})),
|
||||
@@ -307,6 +327,9 @@ fn structured_junk() {
|
||||
),
|
||||
("effect.apply", json!({"effect": "video.deinterlace", "params": {"eliminate": 7, "create": ["x"]}})),
|
||||
("effect.apply", json!({"effect": "video.ntscColors", "params": {"junk": 1e308}})),
|
||||
("effect.apply", json!({"effect": "distort.diffuseGlow", "params": {"graininess": "x", "glowAmount": 1e308, "clearAmount": -1e308}})),
|
||||
("effect.apply", json!({"effect": "distort.glass", "params": {"distortion": 1e308, "smoothness": "NaN", "texture": 3, "scaling": -1}})),
|
||||
("effect.apply", json!({"effect": "distort.oceanRipple", "params": {"rippleSize": [1], "rippleMagnitude": 1e308}})),
|
||||
("effect.apply", json!({"effect": "no.such.effect"})),
|
||||
("effect.remove", json!({"index": 99})),
|
||||
("effect.setParams", json!({"index": 0, "params": null})),
|
||||
|
||||
@@ -61,6 +61,7 @@ pub(crate) fn has_object_fx(n: &Node) -> bool {
|
||||
|| effects::has_container_appearance(n)
|
||||
|| effects::has_crop_marks(n)
|
||||
|| effects::has_adjustment(n)
|
||||
|| effects::has_revolve(n)
|
||||
}
|
||||
|
||||
/// Is `n` a group or layer (whose evaluated art keeps its knockout setting)?
|
||||
@@ -123,6 +124,12 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] {
|
||||
|
||||
/// Visual bounds including geometry effects, stroke outsets and shadows/glows.
|
||||
pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
|
||||
if let Some(art) = effects::revolve_art(n) {
|
||||
return cull_bounds(&art).map(|b| {
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
b.inflate(o, o)
|
||||
});
|
||||
}
|
||||
let bp = node_bezpath(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let g = effected_path(n, &bp);
|
||||
@@ -408,12 +415,13 @@ impl Renderer {
|
||||
_ => None,
|
||||
};
|
||||
let container = is_container(a);
|
||||
let art = match effects::crop_marks_art(a).or_else(|| effects::reshape(a, symbol.as_ref())) {
|
||||
let art = match effects::crop_marks_art(a).or_else(|| effects::revolve_art(a)).or_else(|| effects::reshape(a, symbol.as_ref())) {
|
||||
Some(r) => r,
|
||||
None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()),
|
||||
None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(),
|
||||
None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])),
|
||||
};
|
||||
let art = if effects::has_revolve(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art };
|
||||
let mut art = self.adjusted_art(doc, art, effects::color_map(&a.appearance.effects).as_ref());
|
||||
// A path's geometry effects still apply when it is drawn.
|
||||
let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
|
||||
|
||||
@@ -40,6 +40,69 @@ fn lum(p: [u8; 4]) -> u32 {
|
||||
|
||||
const WHITE: u32 = 765;
|
||||
|
||||
#[test]
|
||||
fn revolve_canvas_matches_baked_art_and_shades_its_surface() {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
bp.move_to((50.0, 15.0));
|
||||
bp.line_to((75.0, 30.0));
|
||||
bp.line_to((75.0, 75.0));
|
||||
bp.line_to((50.0, 90.0));
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
vectorcraft_geom::PathData::from_bezpath(&bp),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.9, 0.2, 0.1)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects.push(fx("threeD.revolve", json!({"rotationX":0,"rotationY":0})));
|
||||
let d = doc_with(Document::new(100.0, 100.0), n);
|
||||
let live = render(&d);
|
||||
let baked = vectorcraft_effects::bake_document(&d).unwrap();
|
||||
let exported = render(&baked);
|
||||
assert_eq!(live.pixels, exported.pixels, "canvas and baked/exported faces agree");
|
||||
let reds: Vec<_> =
|
||||
live.pixels.as_chunks::<4>().0.iter().filter(|p| p[0] as u16 > p[1] as u16 * 2 && p[0] as u16 > p[2] as u16 * 2).map(|p| p[0]).collect();
|
||||
assert!(reds.len() > 1500, "a filled surface, rather than the open source path");
|
||||
assert!(reds.iter().max().unwrap() - reds.iter().min().unwrap() > 80, "lighting varies across the surface");
|
||||
assert_eq!(live.pixels, render_threads(&d, 4).pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_visible_surface_expansion_preserves_the_view_and_transparent_art() {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
bp.move_to((50.0, 15.0));
|
||||
bp.curve_to((80.0, 25.0), (80.0, 75.0), (50.0, 90.0));
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
vectorcraft_geom::PathData::from_bezpath(&bp),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0),
|
||||
);
|
||||
for (angle, rotation, perspective, opacity) in
|
||||
[(360.0, 0.0, 0.0, 1.0), (360.0, -35.0, 55.0, 1.0), (150.0, -28.0, 40.0, 1.0), (360.0, -35.0, 55.0, 0.5)]
|
||||
{
|
||||
n.opacity = opacity;
|
||||
n.appearance.effects = vec![fx(
|
||||
"threeD.revolve",
|
||||
json!({"angle":angle,"rotationX":rotation,"rotationY":25,"perspective":perspective,"segments":32,"expandVisibleOnly":true}),
|
||||
)];
|
||||
let d = doc_with(Document::new(100.0, 100.0), n.clone());
|
||||
let live = render(&d);
|
||||
let mut expanded = d.clone();
|
||||
let id = expanded.layers[0].children().unwrap()[0].id;
|
||||
let source = expanded.node(id).unwrap().clone();
|
||||
let mut stroke = |_: &mut Document, _: &vectorcraft_geom::PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None;
|
||||
let art = vectorcraft_effects::expand_leaf(&mut expanded, &source, &mut stroke).unwrap();
|
||||
*expanded.node_mut(id).unwrap() = art;
|
||||
let flat = render(&expanded);
|
||||
let difference: u64 = live.pixels.iter().zip(&flat.pixels).map(|(a, b)| a.abs_diff(*b) as u64).sum();
|
||||
let average = difference as f64 / live.pixels.len() as f64;
|
||||
assert!(average < 1.0, "appearance differs by {average} channel levels: angle {angle}, rotation {rotation}, opacity {opacity}");
|
||||
if opacity < 1.0 {
|
||||
assert_eq!(live.pixels, flat.pixels);
|
||||
}
|
||||
// Export baking keeps every face, even when the expansion checkbox is checked.
|
||||
assert_eq!(live.pixels, render(&vectorcraft_effects::bake_document(&d).unwrap()).pixels);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 30×30 red image placed at (20, 20).
|
||||
fn image_doc(effects: Vec<Effect>) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -21,6 +21,23 @@ fn fx(id: &str) -> Effect {
|
||||
Effect { id: id.into(), params: json!({}), visible: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_exports_shaded_vector_faces_and_keeps_the_source_live() {
|
||||
let bp = vectorcraft_geom::BezPath::from_svg("M100 30 L130 50 L130 140 L100 160").unwrap();
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
vectorcraft_geom::PathData::from_bezpath(&bp),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects.push(fx("threeD.revolve"));
|
||||
let d = doc_with(n);
|
||||
let before = d.clone();
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.matches("<path").count() > 50, "Revolve should export vector surfaces");
|
||||
assert!(!svg.contains("<image"), "no raster fallback");
|
||||
assert_eq!(d, before, "export leaves source editable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strokes_own_glow_filters_the_stroke_element_only() {
|
||||
let mut n = Node::path(
|
||||
|
||||
@@ -154,6 +154,15 @@ pub fn rich_session() -> Session {
|
||||
exec(&mut s, "symbol.new", json!({"name": "Dot"}));
|
||||
let t = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 370, "text": "Tap to add", "size": 18})));
|
||||
exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 4, "symbol": "Dot"}));
|
||||
// Variables (data merge): both kinds, bound to art, with two dataset rows and one applied,
|
||||
// so format round trips cover the definitions, the bindings and the rows.
|
||||
exec(&mut s, "variable.define", json!({"name": "Title", "kind": "text"}));
|
||||
exec(&mut s, "variable.define", json!({"name": "Badge", "kind": "visibility"}));
|
||||
exec(&mut s, "variable.bind", json!({"variable": "Title", "ids": [tx.0]}));
|
||||
exec(&mut s, "variable.bind", json!({"variable": "Badge", "ids": [a]}));
|
||||
exec(&mut s, "dataset.new", json!({"name": "Row 1", "values": {"Title": "Merged", "Badge": true}}));
|
||||
exec(&mut s, "dataset.new", json!({"name": "Row 2", "values": {"Title": "Other"}}));
|
||||
exec(&mut s, "dataset.select", json!({"name": "Row 1"}));
|
||||
let _ = (a, b, c, l);
|
||||
exec(&mut s, "select.none", json!({}));
|
||||
s
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "vectorcraft-three-d"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Portable surface-of-revolution geometry and projected vector shading"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
@@ -0,0 +1,419 @@
|
||||
//! A portable surface-of-revolution evaluator. No windowing, GPU or document dependencies.
|
||||
//!
|
||||
//! Open profiles generate uncapped surfaces. Faces are projected and sorted back to front,
|
||||
//! then flat shaded. This vector preview uses a painter's algorithm, not a depth buffer:
|
||||
//! intersecting profiles can have ambiguous visibility and are not solid modelling inputs.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod visibility;
|
||||
pub use visibility::{VisibleFace, visible_faces};
|
||||
|
||||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
|
||||
|
||||
/// Resource limits shared by desktop, CLI and browser evaluation.
|
||||
const MAX_PROFILE_POINTS: usize = 1024;
|
||||
const MAX_FACES: usize = 32768;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct V3(f64, f64, f64);
|
||||
|
||||
impl V3 {
|
||||
fn sub(self, b: Self) -> Self {
|
||||
Self(self.0 - b.0, self.1 - b.1, self.2 - b.2)
|
||||
}
|
||||
fn cross(self, b: Self) -> Self {
|
||||
Self(self.1 * b.2 - self.2 * b.1, self.2 * b.0 - self.0 * b.2, self.0 * b.1 - self.1 * b.0)
|
||||
}
|
||||
fn dot(self, b: Self) -> f64 {
|
||||
self.0 * b.0 + self.1 * b.1 + self.2 * b.2
|
||||
}
|
||||
fn unit(self) -> Self {
|
||||
let l = self.dot(self).sqrt();
|
||||
if l < 1e-12 { Self::default() } else { Self(self.0 / l, self.1 / l, self.2 / l) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertical axis is offset outward from the selected edge of the profile's bounds.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Edge {
|
||||
#[default]
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// All angles are degrees; offset is in document points, lighting values are 0..1.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Revolve {
|
||||
pub angle: f64,
|
||||
pub offset: f64,
|
||||
pub edge: Edge,
|
||||
pub rotation_x: f64,
|
||||
pub rotation_y: f64,
|
||||
pub rotation_z: f64,
|
||||
/// Perspective strength, 0 for orthographic projection, up to 1.
|
||||
pub perspective: f64,
|
||||
/// Radial subdivisions for a full turn, clamped to 8..128.
|
||||
pub segments: usize,
|
||||
pub light_azimuth: f64,
|
||||
pub light_elevation: f64,
|
||||
pub light_intensity: f64,
|
||||
pub ambient: f64,
|
||||
pub shade: bool,
|
||||
}
|
||||
|
||||
impl Default for Revolve {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
angle: 360.0,
|
||||
offset: 0.0,
|
||||
edge: Edge::Left,
|
||||
rotation_x: 0.0,
|
||||
rotation_y: 0.0,
|
||||
rotation_z: 0.0,
|
||||
perspective: 0.0,
|
||||
segments: 64,
|
||||
light_azimuth: -45.0,
|
||||
light_elevation: 45.0,
|
||||
light_intensity: 0.8,
|
||||
ambient: 0.25,
|
||||
shade: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(min, max) } else { fallback }
|
||||
}
|
||||
|
||||
impl Revolve {
|
||||
/// Unit bases of the rings that adjust the Euler X, Y and Z view parameters.
|
||||
/// X turns before Y and Z, Y before Z, and Z turns in the screen plane.
|
||||
/// Each pair has a cross product pointing along that parameter's rotation axis.
|
||||
pub fn rotation_planes(self) -> [[[f64; 3]; 2]; 3] {
|
||||
let o = self.sanitized();
|
||||
let x_frame = Self { rotation_x: 0.0, ..o };
|
||||
let y_frame = Self { rotation_x: 0.0, rotation_y: 0.0, ..o };
|
||||
[
|
||||
[x_frame.rotate(V3(0.0, 1.0, 0.0)), x_frame.rotate(V3(0.0, 0.0, 1.0))],
|
||||
[y_frame.rotate(V3(1.0, 0.0, 0.0)), y_frame.rotate(V3(0.0, 0.0, -1.0))],
|
||||
[V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0)],
|
||||
]
|
||||
.map(|plane| plane.map(|v| [v.0, v.1, v.2]))
|
||||
}
|
||||
|
||||
/// The fixed centre of view rotation, in document coordinates.
|
||||
pub fn rotation_origin(self, bounds: Rect) -> Point {
|
||||
let o = self.sanitized();
|
||||
Point::new(if o.edge == Edge::Left { bounds.x0 - o.offset } else { bounds.x1 + o.offset }, bounds.center().y)
|
||||
}
|
||||
|
||||
/// Rotated unit axes for a view-orientation guide (screen Y points down).
|
||||
pub fn view_axes(self) -> [Point; 3] {
|
||||
[V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0), V3(0.0, 0.0, 1.0)].map(|v| {
|
||||
let p = self.sanitized().rotate(v);
|
||||
Point::new(p.0, p.1)
|
||||
})
|
||||
}
|
||||
|
||||
/// The same projected vertical axis used by the surface evaluator.
|
||||
pub fn projected_axis(self, bounds: Rect) -> [Point; 2] {
|
||||
let o = self.sanitized();
|
||||
let axis = match o.edge {
|
||||
Edge::Left => bounds.x0 - o.offset,
|
||||
Edge::Right => bounds.x1 + o.offset,
|
||||
};
|
||||
let center_y = bounds.center().y;
|
||||
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
|
||||
let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0);
|
||||
[bounds.y0, bounds.y1].map(|y| {
|
||||
let v = o.rotate(V3(0.0, y - center_y, 0.0));
|
||||
let factor = distance / (distance - v.2 * o.perspective);
|
||||
Point::new(axis + v.0 * factor, center_y + v.1 * factor)
|
||||
})
|
||||
}
|
||||
|
||||
/// Directional light in view space, shared by shading and interactive guides.
|
||||
pub fn light_direction(self) -> [f64; 3] {
|
||||
let o = self.sanitized();
|
||||
let (sl, cl) = o.light_elevation.to_radians().sin_cos();
|
||||
let (sa, ca) = o.light_azimuth.to_radians().sin_cos();
|
||||
[cl * sa, -sl, cl * ca]
|
||||
}
|
||||
|
||||
/// Light-direction orbits as (centre, cosine basis, sine basis), in view space.
|
||||
/// The first changes azimuth at the current elevation. The second changes elevation
|
||||
/// at the current azimuth. The meridian's valid parameter range is -90..90 degrees.
|
||||
pub fn light_orbits(self) -> [[[f64; 3]; 3]; 2] {
|
||||
let o = self.sanitized();
|
||||
let (se, ce) = o.light_elevation.to_radians().sin_cos();
|
||||
let (sa, ca) = o.light_azimuth.to_radians().sin_cos();
|
||||
[[[0.0, -se, 0.0], [0.0, 0.0, ce], [ce, 0.0, 0.0]], [[0.0, 0.0, 0.0], [sa, 0.0, ca], [0.0, -1.0, 0.0]]]
|
||||
}
|
||||
|
||||
/// Move a light on its current hemisphere, preserving the azimuth at the poles.
|
||||
/// A light behind the object stays behind when its projected handle is dragged.
|
||||
pub fn light_at_on_side(self, point: Point) -> [f64; 2] {
|
||||
let o = self.sanitized();
|
||||
let point = Point::new(finite(point.x, 0.0, -1e8, 1e8), finite(point.y, 0.0, -1e8, 1e8));
|
||||
let length = point.x.hypot(point.y).max(1.0);
|
||||
let (x, y) = (point.x / length, point.y / length);
|
||||
let z = (1.0 - x * x - y * y).max(0.0).sqrt().copysign(o.light_direction()[2]);
|
||||
let azimuth = if x.hypot(z) < 1e-9 { o.light_azimuth } else { x.atan2(z).to_degrees() };
|
||||
[azimuth, (-y).clamp(-1.0, 1.0).asin().to_degrees()]
|
||||
}
|
||||
|
||||
/// A dragged light position on the front hemisphere, in normalized view coordinates.
|
||||
pub fn light_at(point: Point) -> [f64; 2] {
|
||||
let length = point.x.hypot(point.y).max(1.0);
|
||||
let (x, y) = (point.x / length, point.y / length);
|
||||
let z = (1.0 - x * x - y * y).max(0.0).sqrt();
|
||||
[x.atan2(z).to_degrees(), (-y).clamp(-1.0, 1.0).asin().to_degrees()]
|
||||
}
|
||||
|
||||
fn sanitized(self) -> Self {
|
||||
Self {
|
||||
angle: finite(self.angle, 360.0, 0.0, 360.0),
|
||||
offset: finite(self.offset, 0.0, 0.0, 1e5),
|
||||
rotation_x: finite(self.rotation_x, 0.0, -360.0, 360.0),
|
||||
rotation_y: finite(self.rotation_y, 0.0, -360.0, 360.0),
|
||||
rotation_z: finite(self.rotation_z, 0.0, -360.0, 360.0),
|
||||
perspective: finite(self.perspective, 0.0, 0.0, 1.0),
|
||||
segments: self.segments.clamp(8, 128),
|
||||
light_azimuth: finite(self.light_azimuth, -45.0, -360.0, 360.0),
|
||||
light_elevation: finite(self.light_elevation, 45.0, -90.0, 90.0),
|
||||
light_intensity: finite(self.light_intensity, 0.8, 0.0, 1.0),
|
||||
ambient: finite(self.ambient, 0.25, 0.0, 1.0),
|
||||
..self
|
||||
}
|
||||
}
|
||||
fn rotate(self, v: V3) -> V3 {
|
||||
let (sx, cx) = self.rotation_x.to_radians().sin_cos();
|
||||
let (sy, cy) = self.rotation_y.to_radians().sin_cos();
|
||||
let (sz, cz) = self.rotation_z.to_radians().sin_cos();
|
||||
let v = V3(v.0, cx * v.1 - sx * v.2, sx * v.1 + cx * v.2);
|
||||
let v = V3(cy * v.0 + sy * v.2, v.1, -sy * v.0 + cy * v.2);
|
||||
V3(cz * v.0 - sz * v.1, sz * v.0 + cz * v.1, v.2)
|
||||
}
|
||||
}
|
||||
|
||||
/// One opaque vector polygon, already projected. Brightness multiplies the source colour.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Face {
|
||||
pub points: Vec<Point>,
|
||||
pub depth: f64,
|
||||
pub brightness: f64,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
pub fn path(&self) -> PathData {
|
||||
let mut bp = BezPath::new();
|
||||
if let Some(first) = self.points.first() {
|
||||
bp.move_to(*first);
|
||||
for p in self.points.iter().skip(1) {
|
||||
bp.line_to(*p);
|
||||
}
|
||||
bp.close_path();
|
||||
}
|
||||
PathData::from_bezpath(&bp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a profile in document space. All subpaths share one axis from `bounds`.
|
||||
/// Invalid or excessively complex inputs return an error, never a partial surface.
|
||||
pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Face>, String> {
|
||||
let o = options.sanitized();
|
||||
if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) {
|
||||
return Err("Revolve requires finite profile coordinates within 100 million points".into());
|
||||
}
|
||||
let axis = match o.edge {
|
||||
Edge::Left => bounds.x0 - o.offset,
|
||||
Edge::Right => bounds.x1 + o.offset,
|
||||
};
|
||||
let center_y = (bounds.y0 + bounds.y1) * 0.5;
|
||||
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
|
||||
let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0);
|
||||
let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1);
|
||||
let sweep = o.angle.to_radians();
|
||||
let [lx, ly, lz] = o.light_direction();
|
||||
let light = V3(lx, ly, lz);
|
||||
let mut faces = Vec::new();
|
||||
let mut count = 0;
|
||||
for sp in &path.subpaths {
|
||||
let mut profile = Vec::new();
|
||||
if let Some(first) = sp.anchors.first() {
|
||||
profile.push(first.p);
|
||||
count += 1;
|
||||
if count > MAX_PROFILE_POINTS {
|
||||
return Err("Revolve profile is too complex".into());
|
||||
}
|
||||
}
|
||||
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) };
|
||||
// Bound work before sampling any data. Curves get sixteen samples, straight edges one.
|
||||
if segments > MAX_PROFILE_POINTS {
|
||||
return Err("Revolve profile is too complex".into());
|
||||
}
|
||||
for i in 0..segments {
|
||||
let curve = sp.segment(i);
|
||||
let samples = if sp.segment_is_line(i) { 1 } else { 16 };
|
||||
for k in 1..=samples {
|
||||
profile.push(curve.eval(k as f64 / samples as f64));
|
||||
count += 1;
|
||||
if count > MAX_PROFILE_POINTS {
|
||||
return Err("Revolve profile is too complex".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
for pair in profile.windows(2) {
|
||||
let [a, b] = pair else { continue };
|
||||
if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) {
|
||||
return Err("Revolve profile contains invalid coordinates".into());
|
||||
}
|
||||
for j in 0..steps {
|
||||
let vertex = |p: Point, step: usize| {
|
||||
let (s, c) = (sweep * step as f64 / steps as f64).sin_cos();
|
||||
o.rotate(V3((p.x - axis) * c, p.y - center_y, (p.x - axis) * s))
|
||||
};
|
||||
let vertices = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)];
|
||||
// The diagonal gives a nonzero normal even when one profile endpoint is on-axis.
|
||||
let mut normal = vertices[1].sub(vertices[0]).cross(vertices[2].sub(vertices[0])).unit();
|
||||
if normal.dot(normal) < 0.5 {
|
||||
normal = vertices[2].sub(vertices[0]).cross(vertices[3].sub(vertices[0])).unit();
|
||||
}
|
||||
if normal.dot(normal) < 0.5 {
|
||||
continue;
|
||||
}
|
||||
// Profiles may run up or down. Shade the visible side of this two-sided surface.
|
||||
if normal.2 < 0.0 {
|
||||
normal = V3(-normal.0, -normal.1, -normal.2);
|
||||
}
|
||||
let brightness = if o.shade { (o.ambient + o.light_intensity * normal.dot(light).max(0.0)).clamp(0.0, 1.0) } else { 1.0 };
|
||||
let points: Vec<_> = vertices
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let factor = distance / (distance - v.2 * o.perspective);
|
||||
Point::new(axis + v.0 * factor, center_y + v.1 * factor)
|
||||
})
|
||||
.collect();
|
||||
if points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
|
||||
return Err("Revolve projection is not finite".into());
|
||||
}
|
||||
let area = points
|
||||
.iter()
|
||||
.zip(points.iter().cycle().skip(1))
|
||||
.take(4)
|
||||
.map(|(a, b)| (a.x - axis) * (b.y - center_y) - (b.x - axis) * (a.y - center_y))
|
||||
.sum::<f64>();
|
||||
if area.abs() < 1e-10 {
|
||||
continue;
|
||||
}
|
||||
faces.push(Face { points, depth: vertices.iter().map(|v| v.2).sum::<f64>() / 4.0, brightness });
|
||||
if faces.len() > MAX_FACES {
|
||||
return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
faces.sort_by(|a, b| a.depth.total_cmp(&b.depth));
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() {
|
||||
for azimuth in [-170.0f64, -45.0, 0.0, 83.0, 167.0] {
|
||||
for elevation in [-85.0f64, -30.0, 0.0, 45.0, 90.0] {
|
||||
let o = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..Default::default() };
|
||||
let direction = o.light_direction();
|
||||
for ([center, u, v], angle) in o.light_orbits().into_iter().zip([azimuth, elevation]) {
|
||||
let (s, c) = angle.to_radians().sin_cos();
|
||||
for i in 0..3 {
|
||||
assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
let [a, e] = o.light_at_on_side(Point::new(direction[0], direction[1]));
|
||||
assert!((a - azimuth).abs() < 1e-9 && (e - elevation).abs() < 1e-9, "{azimuth}, {elevation} -> {a}, {e}");
|
||||
}
|
||||
}
|
||||
let o = Revolve { light_azimuth: 135.0, ..Default::default() };
|
||||
let [azimuth, elevation] = o.light_at_on_side(Point::new(0.3, -0.4));
|
||||
let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction();
|
||||
assert!((x - 0.3).abs() < 1e-12 && (y + 0.4).abs() < 1e-12 && z < 0.0);
|
||||
let [a, e] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY));
|
||||
assert!(a.is_finite() && e.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_axes_match_euler_parameter_rotation_and_origin_stays_fixed() {
|
||||
let o = Revolve { rotation_x: 27.0, rotation_y: -39.0, rotation_z: 53.0, ..Default::default() };
|
||||
let seed = V3(0.3, -0.5, 0.7);
|
||||
let before = o.rotate(seed);
|
||||
let planes = o.rotation_planes();
|
||||
for (axis, [u, v]) in planes.iter().enumerate() {
|
||||
let normal = V3(u[0], u[1], u[2]).cross(V3(v[0], v[1], v[2]));
|
||||
assert!((normal.dot(normal) - 1.0).abs() < 1e-12);
|
||||
let mut changed = o;
|
||||
match axis {
|
||||
0 => changed.rotation_x += 0.0001,
|
||||
1 => changed.rotation_y += 0.0001,
|
||||
_ => changed.rotation_z += 0.0001,
|
||||
}
|
||||
let actual = changed.rotate(seed).sub(before);
|
||||
let expected = normal.cross(before);
|
||||
let step = 0.0001f64.to_radians();
|
||||
for (a, b) in [(actual.0 / step, expected.0), (actual.1 / step, expected.1), (actual.2 / step, expected.2)] {
|
||||
assert!((a - b).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
assert_eq!(Revolve { edge: Edge::Right, offset: 12.0, ..o }.rotation_origin(Rect::new(20.0, 30.0, 80.0, 100.0)), Point::new(92.0, 65.0));
|
||||
}
|
||||
#[test]
|
||||
fn guides_match_projection_and_dragged_light_matches_shading_direction() {
|
||||
let o = Revolve { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, offset: 10.0, ..Default::default() };
|
||||
assert_eq!(o.view_axes(), [Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]);
|
||||
assert_eq!(o.projected_axis(Rect::new(20.0, 30.0, 80.0, 100.0)), [Point::new(10.0, 30.0), Point::new(10.0, 100.0)]);
|
||||
let [azimuth, elevation] = Revolve::light_at(Point::new(0.3, -0.4));
|
||||
let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction();
|
||||
assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0);
|
||||
let [azimuth, elevation] = Revolve::light_at(Point::new(2.0, -2.0));
|
||||
assert!(azimuth.is_finite() && elevation.is_finite() && elevation.abs() <= 90.0);
|
||||
}
|
||||
fn cylinder() -> (PathData, Rect) {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((40.0, 10.0));
|
||||
bp.line_to((40.0, 90.0));
|
||||
(PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0))
|
||||
}
|
||||
#[test]
|
||||
fn cylinder_has_expected_silhouette_depth_order_and_shading() {
|
||||
let (p, b) = cylinder();
|
||||
let faces = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap();
|
||||
let points: Vec<_> = faces.iter().flat_map(|f| &f.points).collect();
|
||||
assert!((points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min) + 40.0).abs() < 1e-6);
|
||||
assert!((points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max) - 40.0).abs() < 1e-6);
|
||||
assert!(faces.windows(2).all(|w| w[0].depth <= w[1].depth));
|
||||
assert!(faces.iter().any(|f| f.brightness > 0.8) && faces.iter().any(|f| f.brightness < 0.4));
|
||||
}
|
||||
#[test]
|
||||
fn edge_offset_sweep_and_rotation_change_the_surface() {
|
||||
let (p, b) = cylinder();
|
||||
let base = revolve(&p, b, Revolve::default()).unwrap();
|
||||
for o in [
|
||||
Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() },
|
||||
Revolve { angle: 120.0, ..Default::default() },
|
||||
Revolve { rotation_z: 60.0, perspective: 0.8, ..Default::default() },
|
||||
] {
|
||||
assert_ne!(base, revolve(&p, b, o).unwrap());
|
||||
}
|
||||
assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn malformed_and_degenerate_inputs_are_bounded() {
|
||||
let (p, b) = cylinder();
|
||||
let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap();
|
||||
assert!(f.len() <= 128);
|
||||
assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err());
|
||||
assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Vector visibility in the evaluator's paint order. Convex projected faces are cut by the
|
||||
//! nearer faces covering them, irrespective of their winding or which side faces the camera.
|
||||
//! This preserves interiors visible through open ends and partial sweeps.
|
||||
|
||||
use vectorcraft_geom::{PathData, Point, Rect, SubPath};
|
||||
|
||||
use crate::{Face, MAX_FACES};
|
||||
|
||||
const GRID: usize = 32;
|
||||
const MAX_GRID_REFS: usize = 2_000_000;
|
||||
const MAX_WORK: usize = 16_000_000;
|
||||
const MAX_FRAGMENTS: usize = 256;
|
||||
const MAX_OUTPUT_POINTS: usize = 262_144;
|
||||
|
||||
/// The visible portions of one source face, with its original shading and paint order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VisibleFace {
|
||||
pub path: PathData,
|
||||
pub brightness: f64,
|
||||
pub depth: f64,
|
||||
}
|
||||
|
||||
struct Polygon {
|
||||
points: Vec<Point>,
|
||||
bounds: Rect,
|
||||
}
|
||||
|
||||
fn bounds(points: &[Point]) -> Rect {
|
||||
let Some(first) = points.first() else { return Rect::ZERO };
|
||||
points.iter().fold(Rect::from_points(*first, *first), |b, p| b.union_pt(*p))
|
||||
}
|
||||
|
||||
fn area(points: &[Point]) -> f64 {
|
||||
let Some(origin) = points.first() else { return 0.0 };
|
||||
points.iter().zip(points.iter().cycle().skip(1)).take(points.len()).map(|(a, b)| (*a - *origin).cross(*b - *origin)).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
fn clean(mut points: Vec<Point>, epsilon: f64) -> Vec<Point> {
|
||||
points.dedup_by(|a, b| a.distance(*b) <= epsilon);
|
||||
if points.len() > 1 && points.first().zip(points.last()).is_some_and(|(a, b)| a.distance(*b) <= epsilon) {
|
||||
points.pop();
|
||||
}
|
||||
if points.len() < 3 || area(&points).abs() <= epsilon * epsilon {
|
||||
return vec![];
|
||||
}
|
||||
if area(&points) < 0.0 {
|
||||
points.reverse();
|
||||
}
|
||||
points
|
||||
}
|
||||
|
||||
fn overlaps(a: Rect, b: Rect) -> bool {
|
||||
a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
|
||||
}
|
||||
|
||||
/// Split a convex polygon at an occluder's edge. Boundary vertices belong to both halves,
|
||||
/// so independently clipped neighbours share precisely the same intersection positions.
|
||||
fn split(points: &[Point], a: Point, b: Point, epsilon: f64) -> (Vec<Point>, Vec<Point>) {
|
||||
let edge = b - a;
|
||||
let mut inside = vec![];
|
||||
let mut outside = vec![];
|
||||
for (p, q) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) {
|
||||
let dp = edge.cross(*p - a);
|
||||
let dq = edge.cross(*q - a);
|
||||
if dp >= 0.0 {
|
||||
inside.push(*p);
|
||||
}
|
||||
if dp <= 0.0 {
|
||||
outside.push(*p);
|
||||
}
|
||||
if (dp > 0.0 && dq < 0.0) || (dp < 0.0 && dq > 0.0) {
|
||||
let intersection = p.lerp(*q, dp / (dp - dq));
|
||||
inside.push(intersection);
|
||||
outside.push(intersection);
|
||||
}
|
||||
}
|
||||
(clean(inside, epsilon), clean(outside, epsilon))
|
||||
}
|
||||
|
||||
fn subtract(points: Vec<Point>, cover: &Polygon, epsilon: f64, work: &mut usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
if !overlaps(bounds(&points), cover.bounds) {
|
||||
return Ok(vec![points]);
|
||||
}
|
||||
let mut remaining = points;
|
||||
let mut visible = vec![];
|
||||
for (a, b) in cover.points.iter().zip(cover.points.iter().cycle().skip(1)).take(cover.points.len()) {
|
||||
*work += remaining.len();
|
||||
if *work > MAX_WORK {
|
||||
return Err("Revolve visibility exceeds its geometry work limit".into());
|
||||
}
|
||||
let (inside, outside) = split(&remaining, *a, *b, epsilon);
|
||||
if !outside.is_empty() {
|
||||
visible.push(outside);
|
||||
}
|
||||
remaining = inside;
|
||||
if remaining.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(visible)
|
||||
}
|
||||
|
||||
struct Index {
|
||||
cells: Vec<Vec<usize>>,
|
||||
bounds: Rect,
|
||||
refs: usize,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
fn tiles(&self, b: Rect) -> impl Iterator<Item = usize> {
|
||||
let cell = |v: f64, lo: f64, size: f64| (((v - lo) / size * GRID as f64).floor() as usize).min(GRID - 1);
|
||||
let x0 = cell(b.x0, self.bounds.x0, self.bounds.width());
|
||||
let x1 = cell(b.x1, self.bounds.x0, self.bounds.width());
|
||||
let y0 = cell(b.y0, self.bounds.y0, self.bounds.height());
|
||||
let y1 = cell(b.y1, self.bounds.y0, self.bounds.height());
|
||||
(y0..=y1).flat_map(move |y| (x0..=x1).map(move |x| y * GRID + x))
|
||||
}
|
||||
|
||||
fn insert(&mut self, b: Rect, face: usize) -> Result<(), String> {
|
||||
let tiles: Vec<_> = self.tiles(b).collect();
|
||||
self.refs += tiles.len();
|
||||
if self.refs > MAX_GRID_REFS {
|
||||
return Err("Revolve visibility exceeds its spatial index limit".into());
|
||||
}
|
||||
for tile in tiles {
|
||||
if let Some(cell) = self.cells.get_mut(tile) {
|
||||
cell.push(face);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove covered portions of opaque faces in back-to-front paint order. This follows the
|
||||
/// live evaluator's painter visibility, including its approximation for intersecting surfaces.
|
||||
/// Resource limits return an error; callers can retain the original complete surface safely.
|
||||
pub fn visible_faces(faces: &[Face]) -> Result<Vec<VisibleFace>, String> {
|
||||
if faces.len() > MAX_FACES {
|
||||
return Err("Too many Revolve faces for visibility evaluation".into());
|
||||
}
|
||||
if faces.iter().any(|f| {
|
||||
f.points.len() > 4
|
||||
|| !f.brightness.is_finite()
|
||||
|| !f.depth.is_finite()
|
||||
|| f.points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8)
|
||||
}) {
|
||||
return Err("Revolve visibility requires finite projected convex faces".into());
|
||||
}
|
||||
let all = faces.iter().flat_map(|f| &f.points).copied().collect::<Vec<_>>();
|
||||
let total = bounds(&all);
|
||||
if total.width() <= 0.0 || total.height() <= 0.0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let epsilon = (total.width().max(total.height()) * 1e-10).clamp(1e-8, 1e-3);
|
||||
let polygons: Vec<_> = faces
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let points = clean(f.points.clone(), epsilon);
|
||||
Polygon { bounds: bounds(&points), points }
|
||||
})
|
||||
.collect();
|
||||
// Generated quads are convex. Validate the public helper rather than accepting concave
|
||||
// callers and silently deleting geometry they can see.
|
||||
for p in &polygons {
|
||||
if p.points.iter().enumerate().any(|(i, a)| {
|
||||
let Some(b) = p.points.get((i + 1) % p.points.len()) else { return false };
|
||||
let Some(c) = p.points.get((i + 2) % p.points.len()) else { return false };
|
||||
(*b - *a).cross(*c - *b) < -epsilon * b.distance(*a).max(1.0)
|
||||
}) {
|
||||
return Err("Revolve visibility requires convex projected faces".into());
|
||||
}
|
||||
}
|
||||
let mut index = Index { cells: vec![vec![]; GRID * GRID], bounds: total, refs: 0 };
|
||||
let mut seen = vec![usize::MAX; faces.len()];
|
||||
let (mut out, mut work, mut output_points) = (vec![], 0, 0);
|
||||
for (source, (face, polygon)) in faces.iter().zip(&polygons).enumerate().rev() {
|
||||
if polygon.points.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut fragments = vec![polygon.points.clone()];
|
||||
'covers: for tile in index.tiles(polygon.bounds) {
|
||||
for &candidate in index.cells.get(tile).into_iter().flatten() {
|
||||
work += 1;
|
||||
if work > MAX_WORK {
|
||||
return Err("Revolve visibility exceeds its geometry work limit".into());
|
||||
}
|
||||
if let Some(mark) = seen.get_mut(candidate)
|
||||
&& *mark != source
|
||||
{
|
||||
*mark = source;
|
||||
if let Some(cover) = polygons.get(candidate)
|
||||
&& overlaps(polygon.bounds, cover.bounds)
|
||||
{
|
||||
let mut remaining = vec![];
|
||||
for fragment in fragments {
|
||||
remaining.extend(subtract(fragment, cover, epsilon, &mut work)?);
|
||||
if remaining.len() > MAX_FRAGMENTS {
|
||||
return Err("Revolve visibility creates too many fragments".into());
|
||||
}
|
||||
}
|
||||
fragments = remaining;
|
||||
if fragments.is_empty() {
|
||||
break 'covers;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if fragments.is_empty() {
|
||||
continue;
|
||||
}
|
||||
output_points += fragments.iter().map(Vec::len).sum::<usize>();
|
||||
if output_points > MAX_OUTPUT_POINTS {
|
||||
return Err("Revolve visibility creates too many vector anchors".into());
|
||||
}
|
||||
let path = PathData::new(fragments.into_iter().map(|p| SubPath::polyline(&p, true)).collect());
|
||||
out.push(VisibleFace { path, brightness: face.brightness, depth: face.depth });
|
||||
// A completely covered face need not occupy the grid. Its coverage already belongs
|
||||
// to nearer faces; this keeps repeated overlapping surfaces bounded in practice.
|
||||
index.insert(polygon.bounds, source)?;
|
||||
}
|
||||
out.reverse();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Edge, Revolve, revolve};
|
||||
use vectorcraft_geom::{BezPath, Shape};
|
||||
|
||||
fn face(rect: Rect, brightness: f64) -> Face {
|
||||
Face {
|
||||
points: vec![Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)],
|
||||
depth: brightness,
|
||||
brightness,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_faces_disappear_and_partial_faces_are_trimmed_in_paint_order() {
|
||||
let faces =
|
||||
vec![face(Rect::new(2.0, 2.0, 8.0, 8.0), 0.1), face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.2), face(Rect::new(5.0, 0.0, 15.0, 10.0), 0.8)];
|
||||
let visible = visible_faces(&faces).unwrap();
|
||||
assert_eq!(visible.len(), 2);
|
||||
assert_eq!(visible[0].brightness, 0.2);
|
||||
assert_eq!(visible[0].path.bounds(), Some(Rect::new(0.0, 0.0, 5.0, 10.0)));
|
||||
assert!((visible[0].path.to_bezpath().area() - 50.0).abs() < 1e-8);
|
||||
assert!((visible[1].path.to_bezpath().area() - 100.0).abs() < 1e-8);
|
||||
// Winding and equal depth do not determine visibility; actual paint order does.
|
||||
let mut flipped = faces.clone();
|
||||
for f in &mut flipped {
|
||||
f.points.reverse();
|
||||
f.depth = 0.0;
|
||||
}
|
||||
let other = visible_faces(&flipped).unwrap();
|
||||
assert_eq!(visible.iter().map(|f| f.brightness).collect::<Vec<_>>(), other.iter().map(|f| f.brightness).collect::<Vec<_>>());
|
||||
assert_eq!(visible[0].path.bounds(), other[0].path.bounds());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interior_surface_remains_visible_through_an_opening() {
|
||||
let faces = vec![
|
||||
face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.1),
|
||||
face(Rect::new(0.0, 0.0, 10.0, 3.0), 0.2),
|
||||
face(Rect::new(0.0, 7.0, 10.0, 10.0), 0.3),
|
||||
face(Rect::new(0.0, 3.0, 3.0, 7.0), 0.4),
|
||||
face(Rect::new(7.0, 3.0, 10.0, 7.0), 0.5),
|
||||
];
|
||||
let visible = visible_faces(&faces).unwrap();
|
||||
assert_eq!(visible[0].brightness, 0.1);
|
||||
assert_eq!(visible[0].path.bounds(), Some(Rect::new(3.0, 3.0, 7.0, 7.0)));
|
||||
assert_eq!(visible[0].path.to_bezpath().winding(Point::new(5.0, 5.0)), 1);
|
||||
assert_eq!(visible[0].path.to_bezpath().winding(Point::new(1.0, 1.0)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolved_open_profiles_match_visible_paint_for_both_axis_sides_and_perspective() {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((40.0, 10.0));
|
||||
bp.curve_to((75.0, 20.0), (75.0, 80.0), (40.0, 90.0));
|
||||
let path = PathData::from_bezpath(&bp);
|
||||
for edge in [Edge::Left, Edge::Right] {
|
||||
for angle in [155.0, 360.0] {
|
||||
let options = Revolve {
|
||||
edge,
|
||||
angle,
|
||||
offset: 8.0,
|
||||
segments: 32,
|
||||
rotation_x: -32.0,
|
||||
rotation_y: 24.0,
|
||||
rotation_z: 19.0,
|
||||
perspective: 0.7,
|
||||
..Default::default()
|
||||
};
|
||||
let faces = revolve(&path, path.bounds().unwrap(), options).unwrap();
|
||||
let visible = visible_faces(&faces).unwrap();
|
||||
assert!(visible.len() <= faces.len());
|
||||
let all: Vec<_> = faces.iter().map(|f| (f.path().to_bezpath(), f.brightness)).collect();
|
||||
let cut: Vec<_> = visible.iter().map(|f| (f.path.to_bezpath(), f.brightness)).collect();
|
||||
let b = bounds(&faces.iter().flat_map(|f| &f.points).copied().collect::<Vec<_>>());
|
||||
for x in 0..48 {
|
||||
for y in 0..48 {
|
||||
let p = Point::new(b.x0 + b.width() * (x as f64 + 0.337) / 48.0, b.y0 + b.height() * (y as f64 + 0.413) / 48.0);
|
||||
let expected = all.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, value)| value);
|
||||
let actual = cut.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, value)| value);
|
||||
assert_eq!(actual, expected, "{edge:?}, {angle}, {p:?}");
|
||||
assert!(cut.iter().filter(|(bp, _)| bp.winding(p) != 0).count() <= 1, "expanded surfaces do not cover hidden regions");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_is_bounded_for_repeated_faces_and_rejects_invalid_input() {
|
||||
let faces = vec![face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.5); MAX_FACES];
|
||||
assert_eq!(visible_faces(&faces).unwrap().len(), 1);
|
||||
assert!(visible_faces(&vec![faces[0].clone(); MAX_FACES + 1]).is_err());
|
||||
let mut bad = faces[0].clone();
|
||||
bad.points[0].x = f64::NAN;
|
||||
assert!(visible_faces(&[bad]).is_err());
|
||||
assert!(visible_faces(&[]).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ vectorcraft-tools = { workspace = true }
|
||||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-svg = { workspace = true }
|
||||
vectorcraft-effects = { workspace = true }
|
||||
vectorcraft-three-d = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
vectorcraft-pdf = { workspace = true }
|
||||
vectorcraft-cad = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The VectorCraft brand mark: the app icon (the engraved dragon, `assets/app-icon/`, see its
|
||||
//! README). The PNG carries the icon's rounded corners; it is decoded once per context into a
|
||||
//! mipmapped texture, so it stays crisp from the 22 pt app-bar mark to the About box.
|
||||
//! mipmapped texture, so it stays crisp from the 18 pt app-bar mark to the About box.
|
||||
|
||||
use egui::{Color32, Context, Id, Rect, TextureHandle, TextureOptions, Ui, pos2};
|
||||
|
||||
@@ -36,6 +36,12 @@ pub fn paint_mark(ui: &Ui, r: Rect) {
|
||||
ui.painter().image(texture(ui.ctx()).id(), r, uv, Color32::WHITE);
|
||||
}
|
||||
|
||||
/// The mark's texture id (tests: find the painted marks among the shapes).
|
||||
#[cfg(test)]
|
||||
pub fn texture_id(ctx: &Context) -> egui::TextureId {
|
||||
texture(ctx).id()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -55,6 +61,9 @@ mod tests {
|
||||
let ctx = Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
// The system title bar hides the in-app mark (the OS draws its own icon), so paint the
|
||||
// app-drawn bar here.
|
||||
app.custom_titlebar = true;
|
||||
let mut frame = || {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::chrome::app_bar(&mut app, ui));
|
||||
let uploads = out.textures_delta.set.values().flat_map(|d| d.iter()).filter(|d| d.image.size() == [128, 128]).count();
|
||||
@@ -72,7 +81,7 @@ mod tests {
|
||||
let (uploads, marks) = frame();
|
||||
assert_eq!(uploads, 1);
|
||||
assert_eq!(marks.len(), 1);
|
||||
assert_eq!(marks[0].size(), egui::vec2(22.0, 22.0));
|
||||
assert_eq!(marks[0].size(), egui::vec2(18.0, 18.0));
|
||||
// Later frames reuse the texture.
|
||||
let (uploads, marks) = frame();
|
||||
assert_eq!((uploads, marks.len()), (0, 1));
|
||||
|
||||
@@ -175,7 +175,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag());
|
||||
// A press on the canvas while the context menu is open only closes it (a drag too, which egui
|
||||
// alone would leave open); the tool doesn't get it.
|
||||
if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) {
|
||||
if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) {
|
||||
// The artboard's 3D gizmo owns these gestures while its options are open.
|
||||
// Do not let a ring drag also move or deselect the editable source path.
|
||||
} else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) {
|
||||
egui::Popup::close_all(ui.ctx());
|
||||
} else {
|
||||
handle_input(app, ui, &resp, rect);
|
||||
@@ -401,7 +404,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
|
||||
// Selection visuals and tool overlays.
|
||||
if app.ui.view.edges {
|
||||
let revolve_edit = app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active);
|
||||
if app.ui.view.edges && !revolve_edit {
|
||||
hover_highlight(app, &painter, &xf);
|
||||
selection_overlay(app, &painter, &xf);
|
||||
if app.ui.view.text_threads {
|
||||
@@ -413,7 +417,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
let view_info = app.view_info();
|
||||
// View → Hide Gradient Annotator hides the Gradient tool's annotator.
|
||||
if app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator {
|
||||
if !revolve_edit && (app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator) {
|
||||
let overlays = app.session.overlays(view_info);
|
||||
draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs));
|
||||
}
|
||||
@@ -423,7 +427,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if app.ui.view.rulers && app.ui.screen_mode < 3 {
|
||||
rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t);
|
||||
}
|
||||
if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 {
|
||||
if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 {
|
||||
task_bar(app, ui, &xf);
|
||||
}
|
||||
if app.ui.screen_mode < 3 {
|
||||
|
||||
+155
-44
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use egui::{CornerRadius, Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use egui::{CornerRadius, Sense, Stroke, Ui, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
@@ -12,25 +12,37 @@ use crate::theme::{self, Tokens};
|
||||
use crate::widgets;
|
||||
use crate::{VectorcraftApp, icons, menus, titlebar};
|
||||
|
||||
/// The application bar's height, in points (compact, as in PhotoCraft).
|
||||
pub const APP_BAR_HEIGHT: f32 = 32.0;
|
||||
/// The brand mark's side.
|
||||
const MARK: f32 = 18.0;
|
||||
/// Height of the workspace switcher.
|
||||
const WIDGET_H: f32 = 22.0;
|
||||
|
||||
/// The application bar: brand mark, Home, menus, then Discord, search and the workspace switcher
|
||||
/// at the right. With [`VectorcraftApp::custom_titlebar`] it is also the window's title bar
|
||||
/// ([`titlebar`]): the caption buttons take the right end and the rest of the bar drags the window.
|
||||
/// With the system title bar (Windows and Linux, `system_title_bar`) the OS draws its own icon and
|
||||
/// title, so the in-app bar shows Home, menus and workspace controls only: no brand mark.
|
||||
pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let custom = app.custom_titlebar;
|
||||
let left = if app.integrated_titlebar { 78 } else { 8 };
|
||||
let system = system_title_bar(app);
|
||||
let left = app.titlebar_inset.clamp(8.0, f32::from(i8::MAX)) as i8;
|
||||
let frame = egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: if custom { 0 } else { 14 }, top: 0, bottom: 0 });
|
||||
let bar = egui::Panel::top("app_bar").exact_size(44.0).frame(frame.stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
|
||||
let bar = egui::Panel::top("app_bar").exact_size(APP_BAR_HEIGHT).frame(frame.stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
|
||||
if custom {
|
||||
titlebar::drag_area(ui, ui.max_rect());
|
||||
}
|
||||
ui.horizontal_centered(|ui| {
|
||||
// Brand mark: the app icon.
|
||||
let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover());
|
||||
crate::brand::paint_mark(ui, r);
|
||||
ui.add_space(4.0);
|
||||
// With the system title bar the OS draws its own icon: no in-app brand mark.
|
||||
if !system {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(MARK, MARK), Sense::hover());
|
||||
crate::brand::paint_mark(ui, r);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
let on_home = menus::home_showing(app);
|
||||
if widgets::icon_button(ui, "house", tl!("Home"), on_home, 24.0).clicked() {
|
||||
if widgets::icon_button(ui, "house", tl!("Home"), on_home, 20.0).clicked() {
|
||||
app.run("app.home", json!({})).ok();
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
@@ -43,8 +55,8 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
menus::menu_bar(app, ui)
|
||||
};
|
||||
// The right-side group fills the space after the menus from the right; when it runs
|
||||
// short, Discord goes first (it is also under Help), then the search box becomes an
|
||||
// icon, then the workspace switcher narrows.
|
||||
// short, Discord goes first (it is also under Help), then the search icon (the palette
|
||||
// stays under its shortcut), then the workspace switcher narrows.
|
||||
let full = ui.max_rect();
|
||||
let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() };
|
||||
let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom()));
|
||||
@@ -55,45 +67,31 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let ws = ui.painter().layout_no_wrap(ws_name, egui::FontId::proportional(12.0), t.text);
|
||||
let ws_w = (ws.size().x + 36.0).clamp(112.0, 190.0);
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
let with_search = ws_w + 8.0 + gap + 200.0;
|
||||
let search_full = room >= with_search;
|
||||
let discord = room >= with_search + 10.0 + gap + crate::community::discord_width(ui, false);
|
||||
let ws_w = if search_full { ws_w } else { ws_w.min(room - 8.0 - gap - 24.0).max(64.0) };
|
||||
// The search icon, as in PhotoCraft: shown while a minimal switcher fits beside it.
|
||||
let icon = 28.0;
|
||||
let search = room >= 64.0 + 8.0 + gap + icon;
|
||||
let ws_w = ws_w.min(room - 8.0 - gap - if search { icon } else { 0.0 }).max(64.0);
|
||||
let discord = search && room >= ws_w + 8.0 + gap + icon + 10.0 + gap + crate::community::discord_width(ui, false);
|
||||
let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
let ui = &mut rui;
|
||||
// Workspace switcher: shows the current workspace, opens Window → Workspace.
|
||||
let (wr, wresp) = ui.allocate_exact_size(vec2(ws_w, 24.0), Sense::click());
|
||||
let (wr, wresp) = ui.allocate_exact_size(vec2(ws_w, WIDGET_H), Sense::click());
|
||||
ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel });
|
||||
ui.painter().with_clip_rect(wr.shrink2(vec2(4.0, 0.0))).galley(wr.left_center() + vec2(10.0, -ws.size().y / 2.0), ws, t.text);
|
||||
icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim);
|
||||
let wresp = wresp.on_hover_text(tl!("Switch workspace"));
|
||||
egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui));
|
||||
ui.add_space(8.0);
|
||||
// Search box → command palette.
|
||||
let open_palette = if search_full {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(12), t.input);
|
||||
ui.painter().rect_stroke(
|
||||
r,
|
||||
CornerRadius::same(12),
|
||||
Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }),
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
icons::paint(ui, "search", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(13.0, 13.0)), t.text_dim);
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(26.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
tl!("Search commands and tools"),
|
||||
egui::FontId::proportional(11.5),
|
||||
t.text_dim,
|
||||
);
|
||||
resp.clicked()
|
||||
} else {
|
||||
widgets::icon_button(ui, "search", tl!("Search commands and tools"), false, 24.0).clicked()
|
||||
};
|
||||
if open_palette {
|
||||
app.ui.palette_open = true;
|
||||
app.ui.palette_query.clear();
|
||||
// Search → command palette (its shortcut opens it too).
|
||||
if search {
|
||||
let tip = match menus::shortcut_of("help.commandPalette") {
|
||||
Some(sc) => format!("{} ({})", tl!("Search commands and tools"), menus::pretty_shortcut(sc)),
|
||||
None => tl!("Search commands and tools").to_string(),
|
||||
};
|
||||
if widgets::icon_button(ui, "search", &tip, app.ui.palette_open, icon).clicked() {
|
||||
app.ui.palette_open = !app.ui.palette_open;
|
||||
app.ui.palette_query.clear();
|
||||
}
|
||||
}
|
||||
if discord {
|
||||
ui.add_space(10.0);
|
||||
@@ -106,6 +104,38 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the window shows the system's title bar instead of the app drawing its own: Windows
|
||||
/// and Linux with Preferences › User Interface › System Title Bar. macOS always has system
|
||||
/// decorations, but keeps the in-app brand mark, so it never counts as system mode here; nor
|
||||
/// does the web build (the browser tab has no document title), nor an embedder that never
|
||||
/// turned the preference on.
|
||||
pub fn system_title_bar(app: &VectorcraftApp) -> bool {
|
||||
!app.custom_titlebar && app.session.prefs.system_title_bar && !cfg!(any(target_os = "macos", target_arch = "wasm32"))
|
||||
}
|
||||
|
||||
/// The OS window title: the active document's name suffixed with the app name (a `*` prefix
|
||||
/// marks unsaved changes, like the `*` in the document tabs), or just the app name with no
|
||||
/// document open.
|
||||
pub fn window_title(app: &VectorcraftApp) -> String {
|
||||
app.session
|
||||
.active()
|
||||
.map(|d| format!("{}{} \u{2014} VectorCraft", if d.is_dirty() { "*" } else { "" }, d.title()))
|
||||
.unwrap_or_else(|| "VectorCraft".into())
|
||||
}
|
||||
|
||||
/// Keep the OS window title (and the taskbar / Alt-Tab entry) on the active file. Sends
|
||||
/// `ViewportCommand::Title` only when the title changed since the last frame.
|
||||
pub fn sync_window_title(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let want = window_title(app);
|
||||
if app.last_window_title != want {
|
||||
app.last_window_title = want.clone();
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(want));
|
||||
// One more frame: a new title makes AppKit lay its title bar out again, putting the window
|
||||
// buttons back where it keeps them until the next frame centres them on the bar (#968).
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// An anchor button: icon, tooltip, command and the convert command's `to`.
|
||||
type AnchorButton<'a> = (&'a str, &'a str, &'a str, Option<&'a str>);
|
||||
|
||||
@@ -779,10 +809,14 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", theme::semibold(11.0), t.text);
|
||||
ui.add_space(6.0);
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let tool = app.session.tool_id();
|
||||
// The Zoom tool's drag zooms to an area, or with Animated Zoom zooms as it goes.
|
||||
let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool };
|
||||
for (txt, bold) in hint_segments(hint) {
|
||||
let segments = if let Some(d) = app.ui.dialog.as_ref().filter(|d| crate::dialogs::revolve_gizmo::active(d)) {
|
||||
vec![(crate::dialogs::revolve_gizmo::hint(d).into(), false)]
|
||||
} else {
|
||||
let tool = app.session.tool_id();
|
||||
let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool };
|
||||
hint_segments(hint)
|
||||
};
|
||||
for (txt, bold) in segments {
|
||||
let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) };
|
||||
job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() });
|
||||
}
|
||||
@@ -962,4 +996,81 @@ mod tests {
|
||||
let off = hint(&mut app);
|
||||
assert!(off.contains(" to zoom into an area") && !off.contains("Hold"), "{off}");
|
||||
}
|
||||
|
||||
fn title_test_app(custom_titlebar: bool) -> crate::VectorcraftApp {
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
// The preference that makes the shell launch without its own title bar.
|
||||
app.run("prefs.set", json!({"key": "systemTitleBar", "value": !custom_titlebar})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_the_preference_the_mark_stays() {
|
||||
// The default (e.g. the web build): no custom bar, no preference.
|
||||
let app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
assert!(!super::system_title_bar(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_title_with_no_document_is_the_app_name() {
|
||||
assert_eq!(super::window_title(&title_test_app(true)), "VectorCraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_title_follows_the_active_file() {
|
||||
let mut app = title_test_app(true);
|
||||
app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap();
|
||||
assert_eq!(super::window_title(&app), "foo.svg \u{2014} VectorCraft");
|
||||
// Unsaved changes gain a `*` prefix, like the `*` in the document tabs.
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert_eq!(super::window_title(&app), "*foo.svg \u{2014} VectorCraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_mode_hides_the_mark() {
|
||||
for custom in [true, false] {
|
||||
let mut app = title_test_app(custom);
|
||||
app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::app_bar(&mut app, ui));
|
||||
let uploads = out.textures_delta.set.values().flat_map(|d| d.iter()).filter(|d| d.image.size() == [128, 128]).count();
|
||||
out.textures_delta.clear();
|
||||
let marks: Vec<egui::Rect> = out
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Mesh(m) if m.texture_id == crate::brand::texture_id(&ctx) => Some(m.calc_bounds()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let system = super::system_title_bar(&app);
|
||||
assert_eq!(system, !custom && !cfg!(target_os = "macos"));
|
||||
assert_eq!(marks.is_empty(), system, "mark in system mode (custom={custom})");
|
||||
assert_eq!(uploads, if system { 0 } else { 1 }, "mark texture in system mode (custom={custom})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_window_title_sends_only_on_change() {
|
||||
let mut app = title_test_app(true);
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
super::sync_window_title(&mut app, &ctx);
|
||||
assert_eq!(app.last_window_title, "VectorCraft");
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
out.textures_delta.clear();
|
||||
let cmds = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
|
||||
assert!(cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Title(t) if t == "VectorCraft")), "{cmds:?}");
|
||||
// Same document state again: the cached title stops a repeat command.
|
||||
super::sync_window_title(&mut app, &ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
out.textures_delta.clear();
|
||||
let cmds = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
|
||||
assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Title(_))), "repeat Title: {cmds:?}");
|
||||
app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap();
|
||||
super::sync_window_title(&mut app, &ctx);
|
||||
assert_eq!(app.last_window_title, "foo.svg \u{2014} VectorCraft");
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> {
|
||||
("text.areaOptions", "fit") => Some(AREA_FIT),
|
||||
("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE),
|
||||
("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN),
|
||||
("graph.setType", "valueAxis") => Some(GRAPH_VALUE_AXIS),
|
||||
("graph.setType", "tickLength" | "rightTickLength" | "categoryTickLength") => Some(GRAPH_TICK_LENGTH),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -57,6 +59,12 @@ const FIRST_BASELINE: form::Choices =
|
||||
/// Area Type Options › Align (vertical alignment of the lines in each row/column).
|
||||
const VERTICAL_ALIGN: form::Choices = &[("Top", "top"), ("Center", "center"), ("Bottom", "bottom"), ("Justify", "justify")];
|
||||
|
||||
/// Graph Type › Value Axis (series picked on both axes show none, and OK leaves each where it is).
|
||||
const GRAPH_VALUE_AXIS: form::Choices = &[("On Left Side", "left"), ("On Right Side", "right"), ("On Both Sides", "both")];
|
||||
|
||||
/// Graph Type › Tick Marks › Length.
|
||||
const GRAPH_TICK_LENGTH: form::Choices = &[("None", "none"), ("Short", "short"), ("Full Width", "full")];
|
||||
|
||||
/// Closes before running, so a dialog the command opens stays open.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let cmd = d.str("__command");
|
||||
@@ -113,4 +121,21 @@ mod tests {
|
||||
let NodeKind::Text(t) = &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.area.vertical_align, VerticalAlign::Center);
|
||||
}
|
||||
|
||||
/// Graph Type › Value Axis is a dropdown of the values `graph.setType` takes, and OK puts the
|
||||
/// value axis on the side picked.
|
||||
#[test]
|
||||
fn graph_type_value_axis_is_a_dropdown_of_its_sides() {
|
||||
let doc = vectorcraft_engine::find_command("graph.setType").unwrap().params;
|
||||
let values: Vec<&str> = choices("graph.setType", "valueAxis").unwrap().iter().map(|(_, v)| *v).collect();
|
||||
assert!(doc.contains(&format!("valueAxis?: {}", values.join("|"))), "{values:?}");
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150})).unwrap();
|
||||
crate::menus::graph_dialog(&mut app, "graph.setType").unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("valueAxis"), "left");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("valueAxis".into(), json!("both"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "both");
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,12 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String>
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let (id, relative) = (d.str("__effect"), d.bool("relative"));
|
||||
if id == vectorcraft_effects::REVOLVE {
|
||||
super::revolve::body(app, ui, d);
|
||||
let (cmd, params) = command(d);
|
||||
form::preview(app, ui, d, &d.str("__label"), cmd, params);
|
||||
return false;
|
||||
}
|
||||
// Plug-in effects get fields from their parameter schema; colour adjustments their sliders.
|
||||
let changed = match vectorcraft_plugins::effect::installed(&id) {
|
||||
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
|
||||
@@ -159,12 +165,15 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
|
||||
("blur.radial", "method") => Some(&[("Spin", "spin"), ("Zoom", "zoom")]),
|
||||
("blur.radial", "quality") => Some(&[("Draft", "draft"), ("Good", "good"), ("Best", "best")]),
|
||||
("blur.smart", "quality") => Some(&[("Low", "low"), ("Medium", "medium"), ("High", "high")]),
|
||||
("brushStrokes.sprayedStrokes", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS),
|
||||
("distort.glass", "texture") => Some(&vectorcraft_effects::pixel::GLASS_TEXTURES),
|
||||
("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES),
|
||||
("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES),
|
||||
("texture.texturizer", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES),
|
||||
("texture.texturizer", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS),
|
||||
("video.deinterlace", "eliminate") => Some(&[("Odd Fields", "odd"), ("Even Fields", "even")]),
|
||||
("video.deinterlace", "create") => Some(&[("Duplication", "duplication"), ("Interpolation", "interpolation")]),
|
||||
("stylize.innerGlow", "source") => Some(&[("Center", "center"), ("Edge", "edge")]),
|
||||
("path.offsetPath", "joins") => Some(&[("Miter", "miter"), ("Round", "round"), ("Bevel", "bevel")]),
|
||||
("distort.roughen" | "distort.zigZag", "points") => Some(&[("Smooth", "smooth"), ("Corner", "corner")]),
|
||||
_ => None,
|
||||
@@ -418,7 +427,9 @@ mod tests {
|
||||
#[test]
|
||||
fn fields_that_pick_a_value_are_dropdowns_of_its_values() {
|
||||
// The values come from the effect's parameter documentation (`joins: "miter"|"round"|…`).
|
||||
for (effect, key) in [("path.offsetPath", "joins"), ("distort.zigZag", "points"), ("distort.roughen", "points")] {
|
||||
for (effect, key) in
|
||||
[("path.offsetPath", "joins"), ("distort.zigZag", "points"), ("distort.roughen", "points"), ("stylize.innerGlow", "source")]
|
||||
{
|
||||
let doc = vectorcraft_effects::effect_info(effect).unwrap().params;
|
||||
let listed = doc.split(&format!("{key}: ")).nth(1).unwrap().split([',', '}', ' ']).next().unwrap().to_string();
|
||||
let values: Vec<String> = choices(effect, key).unwrap().iter().map(|(_, v)| format!("\"{v}\"")).collect();
|
||||
|
||||
@@ -733,7 +733,7 @@ mod tests {
|
||||
let fr = crate::i18n::Lang::from_code("fr").unwrap();
|
||||
let shown = crate::i18n::message(fr, "2 font file(s) are now in /Fonts; 1 couldn't be added: a.otf: larger than 256 MB");
|
||||
assert!(shown.contains("se trouvent maintenant dans /Fonts") && shown.contains("a.otf") && shown.contains("plus de 256 Mo"), "{shown}");
|
||||
for code in ["es", "fr", "it", "ru"] {
|
||||
for code in ["de", "es", "fr", "it", "ru"] {
|
||||
let lang = crate::i18n::Lang::from_code(code).unwrap();
|
||||
for s in [
|
||||
"every search thread stopped on an internal error (please report this bug)",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! (`links.relink`), and with Apply to All the other missing files are looked for by name in its
|
||||
//! folder; Ignore keeps the images showing their saved preview, and with Apply to All ignores the
|
||||
//! rest. Afterwards, with Preferences › Update Links: Ask When Modified, the modified links are
|
||||
//! offered for update (`links.update`). Files changed while the app was in the background are
|
||||
//! checked when its window comes back to the front ([`after_focus`]).
|
||||
//! offered for update (`links.update`). Files another app changes while the document is open are
|
||||
//! offered the same way ([`ask_update_changed`], from [`vectorcraft_engine::link_watch`]).
|
||||
//!
|
||||
//! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of
|
||||
//! images whose file was modified), `applyToAll`, `path` (the replacement file; picked when empty)
|
||||
@@ -61,6 +61,16 @@ fn open(app: &mut VectorcraftApp, missing: Vec<Value>, modified: Vec<Value>) {
|
||||
|
||||
/// With Update Links: Ask When Modified, offer to read the modified linked files `ids` again.
|
||||
fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
|
||||
ask(app, ids, tl!("{count} linked image(s) changed since this document was saved. Show the new versions?"));
|
||||
}
|
||||
|
||||
/// The same offer for linked files another app changed while the document is open
|
||||
/// ([`vectorcraft_engine::link_watch`]).
|
||||
pub(crate) fn ask_update_changed(app: &mut VectorcraftApp, ids: Vec<Value>) {
|
||||
ask(app, ids, tl!("{count} linked image(s) changed on disk. Show the new versions?"));
|
||||
}
|
||||
|
||||
fn ask(app: &mut VectorcraftApp, ids: Vec<Value>, question: &str) {
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -68,46 +78,10 @@ fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
|
||||
app.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", ids.len()));
|
||||
return;
|
||||
}
|
||||
let detail = crate::i18n::fmt(
|
||||
tl!("{count} linked image(s) changed since this document was saved. Show the new versions?"),
|
||||
&[("count", &ids.len().to_string())],
|
||||
);
|
||||
let detail = crate::i18n::fmt(question, &[("count", &ids.len().to_string())]);
|
||||
super::confirm::ask(app, tl!("Update Modified Links"), &detail, "links.update", json!({ "ids": ids }));
|
||||
}
|
||||
|
||||
/// A linked file as last seen: its path, and its size and modification time when it exists.
|
||||
pub(crate) type FileStamp = (String, Option<(u64, Option<std::time::SystemTime>)>);
|
||||
|
||||
/// The window came back to the front (#925): linked files of the active document changed
|
||||
/// meanwhile are read again with Update Links: Automatically, offered for update with Ask When
|
||||
/// Modified (once per change of a file), and left to the Links panel with Manually. Nothing is
|
||||
/// asked while a dialog is open.
|
||||
pub fn after_focus(app: &mut VectorcraftApp) {
|
||||
if cfg!(target_arch = "wasm32") || app.session.prefs.update_links == "manually" || app.ui.dialog.is_some() || app.session.active().is_none() {
|
||||
return;
|
||||
}
|
||||
let Ok(r) = app.session.execute("links.check", &json!({})) else { return };
|
||||
let rows: Vec<Value> = r["links"].as_array().into_iter().flatten().filter(|l| l["status"] == "modified").cloned().collect();
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
if app.session.prefs.update_links == "automatically" {
|
||||
// A failure shows in the status bar, as the Links panel's Update Link does.
|
||||
let _ = app.run("links.update", json!({ "ids": ids(&rows) }));
|
||||
return;
|
||||
}
|
||||
// The file each link reads (one found elsewhere after a move), its size and when it was last
|
||||
// changed (two changes can share a time on file systems that keep it coarsely).
|
||||
let stamp = |l: &Value| -> FileStamp {
|
||||
let path = l["found"].as_str().or(l["path"].as_str()).unwrap_or_default().to_string();
|
||||
let meta = std::fs::metadata(&path).ok();
|
||||
let at = meta.as_ref().map(|m| (m.len(), m.modified().ok()));
|
||||
(path, at)
|
||||
};
|
||||
let new: Vec<Value> = rows.into_iter().filter(|l| app.links_asked.insert(stamp(l))).collect();
|
||||
ask_update(app, ids(&new));
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let missing = d.fields.get("missing").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
|
||||
@@ -296,58 +270,4 @@ mod tests {
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// #925: a linked file changed while the app was in the background follows Update Links when
|
||||
/// the window comes back to the front.
|
||||
#[test]
|
||||
fn a_link_changed_in_the_background_follows_update_links_on_focus() {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-focus-links-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let pic = dir.join("a.png");
|
||||
// Each version a different size, so no two share a length and modification time.
|
||||
let mut version = 0;
|
||||
let mut change = || {
|
||||
version += 1;
|
||||
let mut out = vec![];
|
||||
image::RgbaImage::from_pixel(100 + version, 50, image::Rgba([40 * version as u8, 0, 0, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
std::fs::write(&pic, out).unwrap();
|
||||
};
|
||||
change();
|
||||
app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap();
|
||||
let modified = |app: &mut VectorcraftApp| app.run("links.check", json!({})).unwrap()["modified"].as_u64().unwrap();
|
||||
// The window coming to the front asks for the check.
|
||||
let mut raw = egui::RawInput { events: vec![egui::Event::WindowFocused(true)], ..Default::default() };
|
||||
app.raw_input_hook(&mut raw);
|
||||
assert!(app.links_check);
|
||||
// Ask When Modified (the default): asked once per change.
|
||||
change();
|
||||
after_focus(&mut app);
|
||||
let d = app.ui.dialog.take().expect("asks");
|
||||
assert_eq!(d.str("__command"), "links.update");
|
||||
after_focus(&mut app);
|
||||
assert!(app.ui.dialog.is_none(), "not twice for the same change");
|
||||
change();
|
||||
after_focus(&mut app);
|
||||
assert!(app.ui.dialog.is_some(), "a new change asks again");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(modified(&mut app), 0);
|
||||
// Automatically: read again without asking.
|
||||
app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap();
|
||||
change();
|
||||
after_focus(&mut app);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(modified(&mut app), 0);
|
||||
// Manually: left to the Links panel.
|
||||
app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap();
|
||||
change();
|
||||
after_focus(&mut app);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(modified(&mut app), 1);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,8 @@ pub mod raster_effects;
|
||||
pub mod rearrange_artboards;
|
||||
pub mod recolor;
|
||||
mod recovery;
|
||||
mod revolve;
|
||||
pub(crate) mod revolve_gizmo;
|
||||
pub mod saturate;
|
||||
mod save_changes;
|
||||
pub mod save_for_web;
|
||||
@@ -88,6 +90,7 @@ pub mod tile_edge_color;
|
||||
mod tools;
|
||||
mod transform;
|
||||
pub mod transform_each;
|
||||
pub mod variables;
|
||||
pub mod width_point;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -212,6 +215,7 @@ registry! {
|
||||
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
|
||||
Recolor: [recolor::KIND] => recolor::SPEC,
|
||||
Command: ["command"] => command::SPEC,
|
||||
Variables: [variables::KIND] => variables::SPEC,
|
||||
Effect: ["effect"] => effect::SPEC,
|
||||
SaveChanges: [crate::unsaved::KIND] => save_changes::SPEC,
|
||||
Preferences: ["preferences"] => DialogSpec::window(crate::prefs_dialog::show, |app, _| crate::prefs_dialog::confirm(app)),
|
||||
@@ -353,6 +357,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
// its first field is to take the keyboard focus (`focus_id`, until a field takes it).
|
||||
let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending"));
|
||||
let Some(mut d) = app.ui.dialog.clone() else {
|
||||
revolve_gizmo::clear(ctx);
|
||||
app.ui.dialog_file = None;
|
||||
ctx.data_mut(|m| {
|
||||
m.remove::<String>(shown_id);
|
||||
@@ -360,6 +365,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
return;
|
||||
};
|
||||
if !revolve_gizmo::active(&d) {
|
||||
revolve_gizmo::clear(ctx);
|
||||
}
|
||||
let focus_first = ctx.data_mut(|m| {
|
||||
if m.get_temp::<String>(shown_id).as_deref() != Some(d.kind.as_str()) {
|
||||
m.insert_temp(shown_id, d.kind.clone());
|
||||
@@ -375,7 +383,14 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let mut cancel = false;
|
||||
let mut discard = false;
|
||||
let heading = (spec.heading)(&d);
|
||||
modal::show(ctx, &heading, egui::Id::new(("dialog", d.kind.as_str())), -40.0, MARGIN, |ui| {
|
||||
let revolves = revolve_gizmo::active(&d);
|
||||
if revolves {
|
||||
revolve_gizmo::show(app, ctx, &mut d);
|
||||
}
|
||||
let initial = if revolves { egui::vec2((ctx.content_rect().width() / 2.0 - 226.0).max(0.0), -40.0) } else { egui::vec2(0.0, -40.0) };
|
||||
let id = egui::Id::new(("dialog", d.kind.as_str()));
|
||||
let id = if revolves { id.with("revolve") } else { id };
|
||||
modal::show_at(ctx, &heading, id, initial, MARGIN, |ui| {
|
||||
// Never wider than the window (a large UI scale in a small window): the text wraps.
|
||||
let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP);
|
||||
ui.set_min_width(spec.min_width.min(room));
|
||||
|
||||
@@ -11,6 +11,11 @@ use crate::theme::{self, Tokens};
|
||||
/// screen's centre until it is moved, with `margin` inside its frame. `add` draws the contents and
|
||||
/// should start with [`heading`] (or [`drag_band`]) so the window can be moved.
|
||||
pub(crate) fn show<R>(ctx: &egui::Context, title: &str, id: Id, nudge: f32, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option<R> {
|
||||
show_at(ctx, title, id, egui::vec2(0.0, nudge), margin, add)
|
||||
}
|
||||
|
||||
/// A modal that starts at a chosen offset, then keeps the user's dragged position.
|
||||
pub(crate) fn show_at<R>(ctx: &egui::Context, title: &str, id: Id, initial: Vec2, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option<R> {
|
||||
let t = Tokens::get(ctx);
|
||||
let dim = backdrop();
|
||||
egui::Area::new(dim.id).order(dim.order).fixed_pos(egui::Pos2::ZERO).show(ctx, |ui| {
|
||||
@@ -23,7 +28,7 @@ pub(crate) fn show<R>(ctx: &egui::Context, title: &str, id: Id, nudge: f32, marg
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, nudge))
|
||||
.anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, initial))
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
|
||||
.show(ctx, add)
|
||||
.and_then(|r| r.inner)
|
||||
@@ -67,9 +72,9 @@ fn handle(id: Id) -> Id {
|
||||
/// its [`drag_band`] is dragged, where it sat when the drag began plus how far the pointer has moved
|
||||
/// since the press, kept on screen (applied before the window places itself, so it follows the
|
||||
/// pointer without a frame's lag).
|
||||
fn offset(ctx: &egui::Context, id: Id, nudge: f32) -> Vec2 {
|
||||
fn offset(ctx: &egui::Context, id: Id, initial: Vec2) -> Vec2 {
|
||||
let (key, start_key) = (id.with("modal-offset"), id.with("modal-drag-start"));
|
||||
let offset = ctx.data(|m| m.get_temp::<Vec2>(key)).unwrap_or(egui::vec2(0.0, nudge));
|
||||
let offset = ctx.data(|m| m.get_temp::<Vec2>(key)).unwrap_or(initial);
|
||||
let dragged = ctx.read_response(handle(id)).is_some_and(|r| r.dragged());
|
||||
let moved = ctx.input(|i| Some(i.pointer.latest_pos()? - i.pointer.press_origin()?));
|
||||
let (true, Some(moved), Some(rect)) = (dragged, moved, ctx.memory(|m| m.area_rect(id))) else {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Compact Revolve controls. The object and rotation gizmo live on the artboard.
|
||||
//! All document changes run through the effect dialog's existing preview/commit commands.
|
||||
|
||||
use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Unit;
|
||||
use vectorcraft_effects::REVOLVE;
|
||||
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, scrub, widgets};
|
||||
|
||||
const LABEL_W: f32 = 102.0;
|
||||
const VALUE_W: f32 = 74.0;
|
||||
const SIDE_W: f32 = 354.0;
|
||||
|
||||
/// Sized to the screen, with scrolling inside the body so OK and Cancel stay reachable.
|
||||
pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
// Values start in drag mode; clicking or Tab enters their text editor.
|
||||
ui.data_mut(|m| m.remove::<bool>(widgets::dialog_focus_flag()));
|
||||
let screen = ui.ctx().content_rect();
|
||||
let width = (screen.width() - 64.0).clamp(240.0, SIDE_W);
|
||||
let height = (screen.height() - 240.0).clamp(120.0, 340.0);
|
||||
ui.set_width(width);
|
||||
egui::ScrollArea::vertical().id_salt("revolve-body").max_height(height).auto_shrink([false, true]).show(ui, |ui| {
|
||||
controls(app, ui, d);
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.allocate_ui_with_layout(vec2((width - 100.0).max(80.0), 32.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(super::revolve_gizmo::hint(d)).size(11.0).color(t.text_dim)).wrap());
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let defaults = vectorcraft_effects::default_params(REVOLVE).unwrap_or(json!({}));
|
||||
let changed = defaults.as_object().is_some_and(|m| {
|
||||
m.iter().any(|(k, v)| v.as_f64().map_or_else(|| d.fields.get(k) != Some(v), |default| (d.f64(k, default) - default).abs() > 1e-6))
|
||||
});
|
||||
let (r, _) = ui.allocate_exact_size(vec2(88.0, 24.0), Sense::hover());
|
||||
let response = ui.interact(r, Id::new("revolve-reset-all"), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset all")));
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
ui.painter().text(r.left_center() + vec2(4.0, 0.0), Align2::LEFT_CENTER, tl!("Reset all"), FontId::proportional(11.5), t.text);
|
||||
dot(ui, r.right_center() - vec2(10.0, 0.0), changed);
|
||||
if response.on_hover_text(tl!("Reset all Revolve settings to their defaults.")).clicked()
|
||||
&& let Some(defaults) = defaults.as_object()
|
||||
{
|
||||
d.fields.extend(defaults.clone());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.spacing_mut().item_spacing.y = 4.0;
|
||||
let lighting = super::revolve_gizmo::lighting(d);
|
||||
if let Some(tab) = widgets::tab_bar(ui, &[tl!("Rotation"), tl!("Lighting")], usize::from(lighting)) {
|
||||
d.fields.insert("__gizmo".into(), json!(if tab == 1 { "light" } else { "rotation" }));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if super::revolve_gizmo::lighting(d) {
|
||||
lighting_controls(ui, d);
|
||||
return;
|
||||
}
|
||||
let unit = app.session.general_unit();
|
||||
heading(ui, tl!("Shape"));
|
||||
number(ui, d, "angle", tl!("Revolve angle"), (0.0, 360.0), "°", 1, None);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
label(ui, tl!("Axis side"));
|
||||
let edge = d.str("edge");
|
||||
let current = if edge == "right" { tl!("Right") } else { tl!("Left") };
|
||||
let width = VALUE_W + rail_width(ui) + 6.0;
|
||||
if let Some(k) = widgets::dropdown(ui, "revolve-edge", current, &[tl!("Left"), tl!("Right")], width) {
|
||||
d.fields.insert("edge".into(), json!(if k == 0 { "left" } else { "right" }));
|
||||
}
|
||||
if reset_dot(ui, "edge", edge != "left", tl!("Left")) {
|
||||
d.fields.insert("edge".into(), json!("left"));
|
||||
}
|
||||
});
|
||||
number(ui, d, "offset", tl!("Offset"), (0.0, 100000.0), "", 1, Some(unit));
|
||||
ui.add_space(10.0);
|
||||
heading(ui, tl!("View"));
|
||||
for (key, name) in [("rotationX", tl!("Rotation X")), ("rotationY", tl!("Rotation Y")), ("rotationZ", tl!("Rotation Z"))] {
|
||||
number(ui, d, key, name, (-360.0, 360.0), "°", 1, None);
|
||||
}
|
||||
number(ui, d, "perspective", tl!("Perspective"), (0.0, 100.0), "%", 1, None);
|
||||
ui.add_space(6.0);
|
||||
egui::CollapsingHeader::new(tl!("Advanced")).id_salt("revolve-advanced").show(ui, |ui| {
|
||||
number(ui, d, "segments", tl!("Segments"), (8.0, 128.0), "", 0, None);
|
||||
super::form::check(ui, d, "__showAxis", tl!("Show axis"));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true);
|
||||
let response = ui.allocate_ui_with_layout(
|
||||
vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.checkbox(&mut visible, egui::RichText::new(tl!("Keep visible surfaces only when expanding")).size(12.0))
|
||||
},
|
||||
).inner;
|
||||
if response.on_hover_text(tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.")).changed() {
|
||||
d.fields.insert("expandVisibleOnly".into(), json!(visible));
|
||||
}
|
||||
if reset_dot(ui, "expandVisibleOnly", !visible, "true") {
|
||||
d.fields.insert("expandVisibleOnly".into(), json!(true));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.horizontal(|ui| {
|
||||
let mut shade = d.bool("shade");
|
||||
if ui.checkbox(&mut shade, tl!("Shading")).changed() {
|
||||
d.fields.insert("shade".into(), json!(shade));
|
||||
}
|
||||
ui.add_space((ui.available_width() - 24.0).max(0.0));
|
||||
if reset_dot(ui, "shade", !shade, "true") {
|
||||
d.fields.insert("shade".into(), json!(true));
|
||||
}
|
||||
});
|
||||
ui.add_enabled_ui(d.bool("shade"), |ui| {
|
||||
number(ui, d, "lightAzimuth", tl!("Direction"), (-360.0, 360.0), "°", 1, None);
|
||||
number(ui, d, "lightElevation", tl!("Elevation"), (-90.0, 90.0), "°", 1, None);
|
||||
number(ui, d, "lightIntensity", tl!("Intensity"), (0.0, 100.0), "%", 1, None);
|
||||
number(ui, d, "ambient", tl!("Ambient"), (0.0, 100.0), "%", 1, None);
|
||||
});
|
||||
}
|
||||
|
||||
fn heading(ui: &mut egui::Ui, text: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(text).font(crate::theme::semibold(13.0)).color(t.text));
|
||||
}
|
||||
|
||||
fn label(ui: &mut egui::Ui, name: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 24.0), Sense::hover());
|
||||
ui.put(r, egui::Label::new(egui::RichText::new(name).size(12.5).color(t.text)).halign(egui::Align::Min).truncate()).on_hover_text(name);
|
||||
scrub::note_label(ui, r);
|
||||
}
|
||||
|
||||
fn rail_width(ui: &egui::Ui) -> f32 {
|
||||
(ui.available_width() - VALUE_W - 24.0 - 18.0).clamp(40.0, 134.0)
|
||||
}
|
||||
|
||||
/// A value can be typed, dragged, scrubbed by its label, or moved on its slider.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option<Unit>) {
|
||||
let default = vectorcraft_effects::default_params(REVOLVE).and_then(|p| p.get(key).and_then(Value::as_f64)).unwrap_or(0.0);
|
||||
let from = |n| unit.map_or(n, |u| u.from_pt(n));
|
||||
let to = |n| unit.map_or(n, |u| u.to_pt(n));
|
||||
let typed = unit.map(|u| widgets::typed_unit(ui.ctx(), u));
|
||||
let mut value = from(d.f64(key, default)).clamp(from(range.0), from(range.1));
|
||||
let suffix = unit.map_or_else(|| suffix.to_string(), |u| format!(" {}", u.suffix()));
|
||||
let mut next = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
label(ui, name);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let response = ui
|
||||
.scope(|ui| {
|
||||
ui.visuals_mut().widgets.inactive.weak_bg_fill = t.input;
|
||||
ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border);
|
||||
ui.add_sized(
|
||||
[VALUE_W, 24.0],
|
||||
egui::DragValue::new(&mut value)
|
||||
.range(from(range.0)..=from(range.1))
|
||||
.speed(if decimals == 0 { 1.0 } else { 0.25 })
|
||||
.max_decimals(decimals)
|
||||
.suffix(&suffix)
|
||||
.update_while_editing(false)
|
||||
.custom_parser(|s| {
|
||||
unit.map_or_else(
|
||||
|| vectorcraft_doc::parse_number(&s.replace(['°', '%'], "")),
|
||||
|u| typed.unwrap_or(u).parse(s).map(|v| u.from_pt(v)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.inner;
|
||||
response.clone().on_hover_text(tl!("Drag values or labels. Double-click a value to type."));
|
||||
if response.changed() {
|
||||
next = Some(value);
|
||||
}
|
||||
if let Some(n) = scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32) {
|
||||
next = Some(n);
|
||||
}
|
||||
// Offset's rail covers a useful working range; larger values can still be typed.
|
||||
let rail_max = if key == "offset" { from(500.0) } else { from(range.1) };
|
||||
let rail_min = from(range.0);
|
||||
let rail = rail_width(ui) + VALUE_W;
|
||||
let slider =
|
||||
widgets::color_slider(ui, ("revolve-slider", key), ((value - rail_min) / (rail_max - rail_min)).clamp(0.0, 1.0) as f32, rail, &|_| {
|
||||
t.input_border
|
||||
});
|
||||
if let (Some(n), _) = slider {
|
||||
next = Some(rail_min + f64::from(n) * (rail_max - rail_min));
|
||||
}
|
||||
if reset_dot(ui, key, (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) {
|
||||
next = Some(from(default));
|
||||
}
|
||||
});
|
||||
if let Some(n) = next.filter(|n| n.is_finite()) {
|
||||
let factor = 10f64.powi(decimals as i32);
|
||||
let n = (n * factor).round() / factor;
|
||||
d.fields.insert(key.into(), json!(to(n.clamp(from(range.0), from(range.1)))));
|
||||
}
|
||||
}
|
||||
|
||||
fn dot(ui: &egui::Ui, p: Pos2, changed: bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if changed {
|
||||
ui.painter().circle_filled(p, 3.0, if ui.is_enabled() { t.accent } else { t.text_disabled });
|
||||
} else {
|
||||
ui.painter().circle_stroke(p, 3.0, Stroke::new(1.0, t.text_disabled));
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover());
|
||||
let response = ui.interact(r, Id::new(("revolve-reset", key)), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset to default")));
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, Tokens::get(ui.ctx()).hover);
|
||||
}
|
||||
dot(ui, r.center(), changed);
|
||||
response.on_hover_text(format!("{}: {default}", tl!("Reset to default"))).clicked()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests_revolve.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,425 @@
|
||||
//! Artboard rotation and light controls for the active Revolve dialog. Gestures edit fields;
|
||||
//! the existing effect preview applies commands and owns OK, Cancel and undo.
|
||||
use egui::{Align2, FontId, Id, Pos2, Rect, Sense, Stroke, Vec2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_effects::{REVOLVE, revolve_options};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_three_d::Revolve;
|
||||
|
||||
use crate::{VectorcraftApp, canvas::Xf, state::Dialog, theme::Tokens};
|
||||
|
||||
const KEYS: [&str; 3] = ["rotationX", "rotationY", "rotationZ"];
|
||||
const SAMPLES: usize = 96;
|
||||
|
||||
pub(crate) fn active(d: &Dialog) -> bool {
|
||||
d.kind == "effect" && d.str("__effect") == REVOLVE
|
||||
}
|
||||
|
||||
pub(crate) fn lighting(d: &Dialog) -> bool {
|
||||
d.str("__gizmo") == "light"
|
||||
}
|
||||
|
||||
pub(crate) fn hint(d: &Dialog) -> &'static str {
|
||||
if !d.bool("preview") || (lighting(d) && !d.bool("shade")) {
|
||||
tl!("Drag values or labels. Double-click a value to type.")
|
||||
} else if lighting(d) {
|
||||
tl!("Drag the light, or its rings. Shift snaps to 15°.")
|
||||
} else {
|
||||
tl!("Drag rings to rotate. Shift snaps to 15°.")
|
||||
}
|
||||
}
|
||||
|
||||
fn drag_id() -> Id {
|
||||
Id::new("revolve-gizmo-drag")
|
||||
}
|
||||
|
||||
pub(crate) fn dragging(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|m| m.get_temp::<Drag>(drag_id())).is_some()
|
||||
}
|
||||
|
||||
pub(super) fn clear(ctx: &egui::Context) {
|
||||
ctx.data_mut(|m| m.remove::<Drag>(drag_id()));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Ring {
|
||||
center: Vec2,
|
||||
u: Vec2,
|
||||
v: Vec2,
|
||||
depth: [f32; 2],
|
||||
}
|
||||
|
||||
impl Ring {
|
||||
fn point(self, angle: f32) -> Vec2 {
|
||||
self.center + self.u * angle.cos() + self.v * angle.sin()
|
||||
}
|
||||
fn tangent(self, angle: f32) -> Vec2 {
|
||||
-self.u * angle.sin() + self.v * angle.cos()
|
||||
}
|
||||
fn phase(self, p: Vec2) -> Option<f32> {
|
||||
let p = p - self.center;
|
||||
let det = self.u.x * self.v.y - self.u.y * self.v.x;
|
||||
// Near edge-on, an ellipse's inverse magnifies tiny pointer movements.
|
||||
if det.abs() < self.u.length().max(self.v.length()).powi(2) * 0.15 {
|
||||
return None;
|
||||
}
|
||||
let x = (p.x * self.v.y - p.y * self.v.x) / det;
|
||||
let y = (p.y * self.u.x - p.x * self.u.y) / det;
|
||||
(x.hypot(y) > 0.1).then(|| y.atan2(x))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Geometry {
|
||||
center: Pos2,
|
||||
radius: f32,
|
||||
rings: [Ring; 3],
|
||||
xf: Xf,
|
||||
bounds: vectorcraft_geom::Rect,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
fn new(app: &VectorcraftApp, d: &Dialog) -> Option<Self> {
|
||||
let state = app.session.active()?;
|
||||
let node = state.doc.node(*state.selection.objects.first()?)?;
|
||||
let bounds = node.geometric_bounds()?;
|
||||
let xf = Xf::new(app.canvas_rect?, app.view()?);
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
let center = xf.to_screen(options.rotation_origin(bounds));
|
||||
if !center.x.is_finite() || !center.y.is_finite() || !xf.rect.contains(center) {
|
||||
return None;
|
||||
}
|
||||
let radius = (bounds.height().abs() * xf.zoom * 0.24).clamp(76.0, 116.0) as f32;
|
||||
let (s, c) = (xf.rot as f32).sin_cos();
|
||||
let screen = |p: [f64; 3], scale: f32| vec2(c * p[0] as f32 - s * p[1] as f32, s * p[0] as f32 + c * p[1] as f32) * radius * scale;
|
||||
let rings = std::array::from_fn(|i| {
|
||||
let [u, v] = options.rotation_planes()[i];
|
||||
let scale = if i == 2 { 1.12 } else { 1.0 };
|
||||
Ring { center: Vec2::ZERO, u: screen(u, scale), v: screen(v, scale), depth: [u[2] as f32, v[2] as f32] }
|
||||
});
|
||||
Some(Self { center, radius, rings, xf, bounds })
|
||||
}
|
||||
|
||||
fn hit(self, p: Pos2) -> Option<(usize, f32)> {
|
||||
let mut best = None;
|
||||
let mut distance = 9.0;
|
||||
for (axis, ring) in self.rings.iter().enumerate() {
|
||||
for i in 0..SAMPLES {
|
||||
let a = i as f32 * std::f32::consts::TAU / SAMPLES as f32;
|
||||
let b = a + std::f32::consts::TAU / SAMPLES as f32;
|
||||
let (start, end) = (self.center + ring.point(a), self.center + ring.point(b));
|
||||
let line = end - start;
|
||||
let t = ((p - start).dot(line) / line.length_sq().max(1e-6)).clamp(0.0, 1.0);
|
||||
let d = p.distance(start + line * t);
|
||||
if d < distance {
|
||||
distance = d;
|
||||
best = Some((axis, a + (b - a) * t));
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn light_rings(self, options: Revolve) -> [Ring; 2] {
|
||||
let (s, c) = (self.xf.rot as f32).sin_cos();
|
||||
let screen = |p: [f64; 3]| vec2(c * p[0] as f32 - s * p[1] as f32, s * p[0] as f32 + c * p[1] as f32) * self.radius * 1.4;
|
||||
options.light_orbits().map(|[center, u, v]| Ring { center: screen(center), u: screen(u), v: screen(v), depth: [u[2] as f32, v[2] as f32] })
|
||||
}
|
||||
|
||||
fn light_labels(self) -> [Rect; 2] {
|
||||
[
|
||||
Rect::from_center_size(self.center + vec2(0.0, self.radius * 1.4 + 24.0), vec2(88.0, 26.0)),
|
||||
Rect::from_center_size(self.center - vec2(self.radius * 1.4 + 32.0, 0.0), vec2(78.0, 26.0)),
|
||||
]
|
||||
}
|
||||
|
||||
fn light_hit(self, p: Pos2, options: Revolve) -> Option<usize> {
|
||||
if let Some(axis) = self.light_labels().iter().position(|r| r.contains(p)) {
|
||||
return Some(axis);
|
||||
}
|
||||
let mut best = None;
|
||||
let mut distance = 9.0;
|
||||
for (axis, ring) in self.light_rings(options).iter().enumerate() {
|
||||
let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (-std::f32::consts::FRAC_PI_2, std::f32::consts::PI) };
|
||||
for i in 0..SAMPLES {
|
||||
let a = start + i as f32 * span / SAMPLES as f32;
|
||||
let (from, to) = (self.center + ring.point(a), self.center + ring.point(a + span / SAMPLES as f32));
|
||||
let line = to - from;
|
||||
let t = ((p - from).dot(line) / line.length_sq().max(1e-6)).clamp(0.0, 1.0);
|
||||
let d = p.distance(from + line * t);
|
||||
if d < distance {
|
||||
distance = d;
|
||||
best = Some(axis);
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Mode {
|
||||
Ring { axis: usize, ring: Ring, phase: f32, degrees: f64 },
|
||||
Orbit,
|
||||
Light,
|
||||
LightRing { axis: usize },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Drag {
|
||||
mode: Mode,
|
||||
geometry: Geometry,
|
||||
last: Pos2,
|
||||
origin: Pos2,
|
||||
fields: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn wrap(n: f64, snap: bool) -> f64 {
|
||||
let n = if snap { (n / 15.0).round() * 15.0 } else { n };
|
||||
(n + 180.0).rem_euclid(360.0) - 180.0
|
||||
}
|
||||
|
||||
pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) {
|
||||
let light_mode = lighting(d);
|
||||
if !d.bool("preview") || (light_mode && !d.bool("shade")) {
|
||||
clear(ctx);
|
||||
return;
|
||||
}
|
||||
let Some(geometry) = Geometry::new(app, d) else { return };
|
||||
let geometry = ctx.data(|m| m.get_temp::<Drag>(drag_id())).map_or(geometry, |drag| drag.geometry);
|
||||
let mut rect = Rect::from_center_size(geometry.center, Vec2::splat(geometry.radius * 3.0 + 48.0));
|
||||
if light_mode {
|
||||
for label in geometry.light_labels() {
|
||||
rect = rect.union(label.expand(2.0));
|
||||
}
|
||||
}
|
||||
egui::Area::new(Id::new("revolve-gizmo-area")).order(egui::Order::Foreground).fixed_pos(rect.min).movable(false).show(ctx, |ui| {
|
||||
ui.set_clip_rect(geometry.xf.rect);
|
||||
let response = ui.allocate_rect(rect.intersect(geometry.xf.rect), Sense::drag());
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
let light = light_position(geometry, options);
|
||||
let hovered = response
|
||||
.hovered()
|
||||
.then(|| response.hover_pos())
|
||||
.flatten()
|
||||
.and_then(|p| if light_mode { geometry.light_hit(p, options).map(|axis| (axis, 0.0)) } else { geometry.hit(p) });
|
||||
if response.drag_started()
|
||||
&& let Some(origin) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
let mode = if light_mode {
|
||||
if origin.distance(light) <= 18.0 {
|
||||
Mode::Light
|
||||
} else if let Some(axis) = geometry.light_hit(origin, options) {
|
||||
Mode::LightRing { axis }
|
||||
} else {
|
||||
Mode::Light
|
||||
}
|
||||
} else if origin.distance(geometry.center) <= 14.0 {
|
||||
Mode::Orbit
|
||||
} else if let Some((axis, phase)) = geometry.hit(origin) {
|
||||
Mode::Ring { axis, ring: geometry.rings[axis], phase, degrees: 0.0 }
|
||||
} else {
|
||||
Mode::Orbit
|
||||
};
|
||||
ui.ctx().memory_mut(|m| m.stop_text_input());
|
||||
ui.data_mut(|m| m.insert_temp(drag_id(), Drag { mode, geometry, last: origin, origin, fields: d.fields.clone() }));
|
||||
}
|
||||
let mut drag = ui.data(|m| m.get_temp::<Drag>(drag_id()));
|
||||
if let Some(current) = &mut drag {
|
||||
if ui.input_mut(|i| i.consume_key(i.modifiers, egui::Key::Escape)) {
|
||||
d.fields = current.fields.clone();
|
||||
d.fields.remove(super::form::PREVIEWED);
|
||||
ui.data_mut(|m| m.remove::<Drag>(drag_id()));
|
||||
drag = None;
|
||||
} else if let Some(p) = response.interact_pointer_pos() {
|
||||
let snap = ui.input(|i| i.modifiers.shift);
|
||||
match &mut current.mode {
|
||||
Mode::Ring { axis, ring, phase, degrees } => {
|
||||
let step = if let Some(next) = ring.phase(p - geometry.center) {
|
||||
let step = (next - *phase + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI;
|
||||
*phase = next;
|
||||
step
|
||||
} else {
|
||||
let tangent = ring.tangent(*phase);
|
||||
let step = (p - current.last).dot(tangent) / tangent.length_sq().max(144.0);
|
||||
*phase += step;
|
||||
step
|
||||
};
|
||||
*degrees += f64::from(step.to_degrees());
|
||||
if let Some(key) = KEYS.get(*axis) {
|
||||
let start = current.fields.get(*key).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
d.fields.insert((*key).into(), json!(wrap(start + *degrees, snap)));
|
||||
}
|
||||
}
|
||||
Mode::Orbit => {
|
||||
let delta = geometry.xf.delta_to_doc(p - current.origin) * geometry.xf.zoom;
|
||||
for (key, delta) in [("rotationX", delta.y * 0.5), ("rotationY", delta.x * 0.5)] {
|
||||
let start = current.fields.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
d.fields.insert(key.into(), json!(wrap(start + delta, snap)));
|
||||
}
|
||||
}
|
||||
Mode::Light => {
|
||||
let start = revolve_options(&serde_json::Value::Object(current.fields.clone()));
|
||||
let projected = light_position(geometry, start) + (p - current.origin);
|
||||
let point = geometry.xf.delta_to_doc(projected - geometry.center) * (geometry.xf.zoom / f64::from(geometry.radius * 1.4));
|
||||
let [azimuth, elevation] = start.light_at_on_side(Point::new(point.x, point.y));
|
||||
d.fields.insert("lightAzimuth".into(), json!(wrap(azimuth, snap)));
|
||||
d.fields.insert(
|
||||
"lightElevation".into(),
|
||||
json!((if snap { (elevation / 15.0).round() * 15.0 } else { elevation }).clamp(-90.0, 90.0)),
|
||||
);
|
||||
}
|
||||
Mode::LightRing { axis } => {
|
||||
let delta = geometry.xf.delta_to_doc(p - current.origin) * geometry.xf.zoom;
|
||||
let start = revolve_options(&serde_json::Value::Object(current.fields.clone()));
|
||||
if *axis == 0 {
|
||||
d.fields.insert("lightAzimuth".into(), json!(wrap(start.light_azimuth + delta.x * 0.6, snap)));
|
||||
} else {
|
||||
let elevation = start.light_elevation - delta.y * 0.6;
|
||||
d.fields.insert(
|
||||
"lightElevation".into(),
|
||||
json!((if snap { (elevation / 15.0).round() * 15.0 } else { elevation }).clamp(-90.0, 90.0)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
current.last = p;
|
||||
ui.data_mut(|m| m.insert_temp(drag_id(), current.clone()));
|
||||
}
|
||||
}
|
||||
if response.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
|
||||
ui.data_mut(|m| m.remove::<Drag>(drag_id()));
|
||||
}
|
||||
if response.hovered() || drag.is_some() {
|
||||
ui.ctx().set_cursor_icon(if drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
||||
}
|
||||
let active = match drag.as_ref().map(|d| d.mode) {
|
||||
Some(Mode::Ring { axis, .. }) => Some(axis),
|
||||
Some(Mode::LightRing { axis }) => Some(axis),
|
||||
_ => hovered.map(|h| h.0),
|
||||
};
|
||||
paint(ui, d, Geometry::new(app, d).unwrap_or(geometry), active, drag.as_ref());
|
||||
response.on_hover_text(hint(d));
|
||||
});
|
||||
}
|
||||
|
||||
fn light_position(g: Geometry, options: Revolve) -> Pos2 {
|
||||
let [x, y, _] = options.light_direction();
|
||||
let (s, c) = (g.xf.rot as f32).sin_cos();
|
||||
g.center + vec2(c * x as f32 - s * y as f32, s * x as f32 + c * y as f32) * g.radius * 1.4
|
||||
}
|
||||
|
||||
fn paint(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option<usize>, drag: Option<&Drag>) {
|
||||
if lighting(d) {
|
||||
paint_light(ui, d, g, active, drag);
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.painter();
|
||||
let colors = [t.axis_x, t.axis_y, t.axis_z];
|
||||
for (axis, ring) in g.rings.iter().enumerate() {
|
||||
let color = if active == Some(axis) { t.warning } else { colors[axis] };
|
||||
for i in 0..SAMPLES {
|
||||
let angle = i as f32 * std::f32::consts::TAU / SAMPLES as f32;
|
||||
let behind = ring.depth[0] * angle.cos() + ring.depth[1] * angle.sin() < -0.05;
|
||||
if behind && i % 3 == 0 {
|
||||
continue;
|
||||
}
|
||||
let points = [g.center + ring.point(angle), g.center + ring.point(angle + std::f32::consts::TAU / SAMPLES as f32)];
|
||||
painter.line_segment(points, Stroke::new(4.0, t.input.gamma_multiply(0.55)));
|
||||
painter.line_segment(
|
||||
points,
|
||||
Stroke::new(if active == Some(axis) { 2.5 } else { 1.75 }, color.gamma_multiply(if behind { 0.55 } else { 1.0 })),
|
||||
);
|
||||
}
|
||||
// Letters make the axes distinguishable without relying on colour alone.
|
||||
let phase = match axis {
|
||||
0 => 0.75,
|
||||
1 => 2.25,
|
||||
_ => 4.7,
|
||||
};
|
||||
let p = g.center + ring.point(phase);
|
||||
painter.circle_filled(p, 9.0, t.panel);
|
||||
painter.text(p, Align2::CENTER_CENTER, ["X", "Y", "Z"][axis], FontId::proportional(11.0), color);
|
||||
}
|
||||
painter.circle(g.center, 8.0, t.panel.gamma_multiply(0.8), Stroke::new(1.5, t.text_dim));
|
||||
painter.line_segment([g.center - vec2(4.0, 0.0), g.center + vec2(4.0, 0.0)], Stroke::new(1.0, t.text_dim));
|
||||
painter.line_segment([g.center - vec2(0.0, 4.0), g.center + vec2(0.0, 4.0)], Stroke::new(1.0, t.text_dim));
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
if d.bool("__showAxis") {
|
||||
painter.line_segment(options.projected_axis(g.bounds).map(|p| g.xf.to_screen(p)), Stroke::new(1.0, t.warning));
|
||||
}
|
||||
if let Some(Drag { mode: Mode::Ring { axis, .. }, .. }) = drag
|
||||
&& let Some(key) = KEYS.get(*axis)
|
||||
{
|
||||
let text = format!("{} {:.1}°", ["X", "Y", "Z"].get(*axis).unwrap_or(&""), d.f64(key, 0.0));
|
||||
let pos = g.center + vec2(0.0, g.radius * 1.12 + 18.0);
|
||||
let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.text_strong);
|
||||
painter.rect_filled(Rect::from_center_size(pos, galley.size() + vec2(14.0, 8.0)), 4.0, t.panel);
|
||||
painter.galley(pos - galley.size() * 0.5, galley, t.text_strong);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_light(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option<usize>, drag: Option<&Drag>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.painter();
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
let radius = g.radius * 1.4;
|
||||
painter.circle_stroke(g.center, radius, Stroke::new(1.0, t.text_dim.gamma_multiply(0.3)));
|
||||
let labels = g.light_labels();
|
||||
for (axis, ring) in g.light_rings(options).iter().enumerate() {
|
||||
let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (-std::f32::consts::FRAC_PI_2, std::f32::consts::PI) };
|
||||
for i in 0..SAMPLES {
|
||||
let a = start + i as f32 * span / SAMPLES as f32;
|
||||
let behind = ring.depth[0] * a.cos() + ring.depth[1] * a.sin() < -0.05;
|
||||
if behind && i % 3 == 0 {
|
||||
continue;
|
||||
}
|
||||
let points = [g.center + ring.point(a), g.center + ring.point(a + span / SAMPLES as f32)];
|
||||
painter.line_segment(points, Stroke::new(4.0, t.panel.gamma_multiply(0.4)));
|
||||
painter.line_segment(
|
||||
points,
|
||||
Stroke::new(if active == Some(axis) { 2.5 } else { 1.5 }, t.warning.gamma_multiply(if behind { 0.45 } else { 0.85 })),
|
||||
);
|
||||
}
|
||||
let label = labels[axis];
|
||||
painter.rect_filled(label, 4.0, if active == Some(axis) { t.hover } else { t.panel });
|
||||
let text = if axis == 0 { tl!("Direction") } else { tl!("Elevation") };
|
||||
painter.text(label.center(), Align2::CENTER_CENTER, text, FontId::proportional(11.0), t.warning);
|
||||
}
|
||||
let p = light_position(g, options);
|
||||
let delta = g.center - p;
|
||||
if delta.length() > 24.0 {
|
||||
let direction = delta.normalized();
|
||||
let normal = vec2(-direction.y, direction.x);
|
||||
let tip = g.center - direction * 12.0;
|
||||
painter.line_segment([p, tip], Stroke::new(1.25, t.warning.gamma_multiply(0.6)));
|
||||
painter.line_segment([tip, tip - direction * 8.0 + normal * 4.0], Stroke::new(1.25, t.warning));
|
||||
painter.line_segment([tip, tip - direction * 8.0 - normal * 4.0], Stroke::new(1.25, t.warning));
|
||||
}
|
||||
painter.circle(g.center, 4.0, t.panel, Stroke::new(1.0, t.text_dim));
|
||||
// An original sun handle. A back light is hollow; the guide's back half is dashed.
|
||||
let front = options.light_direction()[2] >= 0.0;
|
||||
painter.circle(p, 9.0, if front { t.warning } else { t.panel }, Stroke::new(2.0, t.warning));
|
||||
for i in 0..8 {
|
||||
let angle = i as f32 * std::f32::consts::TAU / 8.0;
|
||||
let direction = vec2(angle.cos(), angle.sin());
|
||||
painter.line_segment([p + direction * 13.0, p + direction * 17.0], Stroke::new(1.5, t.warning));
|
||||
}
|
||||
let name = crate::i18n::tr_ctx(crate::i18n::current(), "lighting", "Light");
|
||||
let galley = painter.layout_no_wrap(name.to_string(), FontId::proportional(11.0), t.warning);
|
||||
let name_pos = p - vec2(galley.size().x * 0.5, 25.0 + galley.size().y);
|
||||
painter.rect_filled(Rect::from_min_size(name_pos - vec2(4.0, 2.0), galley.size() + vec2(8.0, 4.0)), 3.0, t.panel);
|
||||
painter.galley(name_pos, galley, t.warning);
|
||||
if drag.is_some() {
|
||||
let text = format!("{} {:.1}° {} {:.1}°", tl!("Direction"), d.f64("lightAzimuth", -45.0), tl!("Elevation"), d.f64("lightElevation", 45.0));
|
||||
let galley = painter.layout_no_wrap(text, FontId::proportional(11.5), t.text_strong);
|
||||
let pos = g.center + vec2(0.0, radius + 56.0);
|
||||
painter.rect_filled(Rect::from_center_size(pos, galley.size() + vec2(12.0, 6.0)), 4.0, t.panel);
|
||||
painter.galley(pos - galley.size() * 0.5, galley, t.text_strong);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests_revolve_gizmo.rs"]
|
||||
mod tests;
|
||||
@@ -228,6 +228,26 @@ fn effect_dialog_applies_to_its_appearance_item() {
|
||||
assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_dialog_preview_cancel_confirm_and_undo() {
|
||||
let mut app = app();
|
||||
app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
app.run("effect.dialog", json!({"effect":"threeD.revolve","item":null})).unwrap();
|
||||
frame(&mut app, Default::default());
|
||||
assert!(app.session.in_interaction());
|
||||
cancel(&mut app);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
app.run("effect.dialog", json!({"effect":"threeD.revolve","item":null})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("angle".into(), json!(180));
|
||||
frame(&mut app, Default::default());
|
||||
confirm(&mut app).unwrap();
|
||||
assert_ne!(app.session.doc().unwrap().doc, before);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialogs_are_as_tall_as_their_content() {
|
||||
// The save prompt, shown after a taller dialog, is as tall as its text and buttons: no empty
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Real pointer interaction with Revolve's compact controls.
|
||||
use super::*;
|
||||
use crate::{dialogs, theme};
|
||||
use egui::{Event, PointerButton, Rect, Vec2, pos2};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
struct Harness {
|
||||
app: VectorcraftApp,
|
||||
ctx: egui::Context,
|
||||
screen: Vec2,
|
||||
texts: Vec<(String, Rect)>,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn new(screen: Vec2) -> Self {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":300,"height":300})).unwrap();
|
||||
app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap();
|
||||
app.run("paint.setFill", json!({"color":"#338ad6"})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect":REVOLVE,"item":null})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
Self { app, ctx, screen, texts: vec![] }
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) {
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, self.screen)), events, ..Default::default() };
|
||||
let mut out = self.ctx.run_ui(input, |ui| dialogs::show(&mut self.app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
fn collect(shape: &egui::Shape, clip: Rect, texts: &mut Vec<(String, Rect)>) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) if clip.contains_rect(t.visual_bounding_rect()) => {
|
||||
texts.push((t.galley.text().to_string(), t.visual_bounding_rect()))
|
||||
}
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, clip, texts)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.texts.clear();
|
||||
out.shapes.iter().for_each(|s| collect(&s.shape, s.clip_rect, &mut self.texts));
|
||||
}
|
||||
|
||||
fn settle(&mut self) {
|
||||
for _ in 0..5 {
|
||||
self.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(&self, text: &str) -> Rect {
|
||||
self.texts.iter().find(|(s, _)| s == text).map(|(_, r)| *r).unwrap_or_else(|| panic!("missing text {text:?}"))
|
||||
}
|
||||
|
||||
fn response(&self, id: impl std::hash::Hash + std::fmt::Debug) -> Rect {
|
||||
self.ctx.read_response(Id::new(id)).unwrap().rect
|
||||
}
|
||||
|
||||
fn click(&mut self, p: Pos2) {
|
||||
self.frame(vec![Event::PointerMoved(p), button(p, true)]);
|
||||
self.frame(vec![button(p, false)]);
|
||||
self.settle();
|
||||
}
|
||||
|
||||
fn drag(&mut self, p: Pos2, delta: Vec2) {
|
||||
self.frame(vec![Event::PointerMoved(p), button(p, true)]);
|
||||
for i in 1..=5 {
|
||||
self.frame(vec![Event::PointerMoved(p + delta * (i as f32 / 5.0))]);
|
||||
}
|
||||
self.frame(vec![button(p + delta, false)]);
|
||||
self.settle();
|
||||
}
|
||||
|
||||
fn value(&self, key: &str) -> f64 {
|
||||
self.app.ui.dialog.as_ref().unwrap().f64(key, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_labels_sliders_and_reset_dots_change_the_live_effect_in_one_undo_step() {
|
||||
let mut h = Harness::new(vec2(1280.0, 900.0));
|
||||
let before = h.app.session.doc().unwrap().doc.clone();
|
||||
h.settle();
|
||||
assert!(!h.rect("Advanced").is_negative(), "Advanced is visible without scrolling in a normal window");
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
assert!(panel.width() < 420.0 && panel.height() < 540.0, "compact controls: {panel:?}");
|
||||
let label = h.rect("Rotation X");
|
||||
h.drag(pos2(label.left() + LABEL_W + 6.0 + VALUE_W / 2.0, label.center().y), vec2(40.0, 0.0));
|
||||
assert!(h.value("rotationX") > 0.0, "the value itself drags");
|
||||
let label = h.rect("Rotation Y");
|
||||
h.drag(label.center(), vec2(40.0, 0.0));
|
||||
assert!(h.value("rotationY") > 0.0, "the label scrubs");
|
||||
let label = h.rect("Revolve angle");
|
||||
h.click(pos2(label.left() + LABEL_W + VALUE_W + 24.0, label.center().y));
|
||||
assert!(h.value("angle") < 180.0, "the slider changes the sweep");
|
||||
h.click(h.response(("revolve-reset", "rotationX")).center());
|
||||
assert_eq!(h.value("rotationX"), 0.0);
|
||||
assert!(h.value("angle") < 180.0, "a dot resets only its own setting");
|
||||
let node = h.app.session.doc().unwrap().doc.node(h.app.session.doc().unwrap().selection.objects[0]).unwrap();
|
||||
assert_eq!(node.appearance.effects[0].params["angle"], json!(h.value("angle")));
|
||||
h.click(h.response("revolve-reset-all").center());
|
||||
let defaults = vectorcraft_effects::default_params(REVOLVE).unwrap();
|
||||
let dialog = h.app.ui.dialog.as_ref().unwrap();
|
||||
for (key, value) in defaults.as_object().unwrap() {
|
||||
assert_eq!(dialog.fields.get(key), Some(value), "reset all: {key}");
|
||||
}
|
||||
dialogs::confirm(&mut h.app).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_off_changes_fields_without_changing_the_document() {
|
||||
let mut h = Harness::new(vec2(1280.0, 900.0));
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false));
|
||||
let before = h.app.session.doc().unwrap().doc.clone();
|
||||
h.settle();
|
||||
h.drag(h.rect("Rotation Y").center(), vec2(40.0, 0.0));
|
||||
assert!(h.value("rotationY") > 0.0);
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
dialogs::cancel(&mut h.app);
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_driven_fields_preview_and_small_windows_keep_the_buttons_reachable() {
|
||||
let mut h = Harness::new(vec2(640.0, 480.0));
|
||||
h.settle();
|
||||
let dialog = h.app.ui.dialog.as_mut().unwrap();
|
||||
dialog.fields.insert("angle".into(), json!(180));
|
||||
h.settle();
|
||||
let state = h.app.session.doc().unwrap();
|
||||
let node = state.doc.node(state.selection.objects[0]).unwrap();
|
||||
assert_eq!(node.appearance.effects[0].params["angle"], json!(180));
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, h.screen);
|
||||
assert!(screen.contains_rect(h.rect("OK")) && screen.contains_rect(h.rect("Cancel")));
|
||||
dialogs::cancel(&mut h.app);
|
||||
assert!(!h.app.session.in_interaction());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() {
|
||||
let mut h = Harness::new(vec2(1280.0, 900.0));
|
||||
h.settle();
|
||||
h.click(h.rect("Lighting").center());
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "light");
|
||||
for label in ["Shading", "Direction", "Elevation", "Intensity", "Ambient"] {
|
||||
assert!(Rect::from_min_size(Pos2::ZERO, h.screen).contains_rect(h.rect(label)));
|
||||
}
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
assert!(panel.width() < 420.0 && panel.height() < 460.0, "compact lighting controls: {panel:?}");
|
||||
h.drag(h.rect("Direction").center(), vec2(24.0, 0.0));
|
||||
assert_ne!(h.value("lightAzimuth"), -45.0);
|
||||
h.click(h.response(("revolve-reset", "lightAzimuth")).center());
|
||||
assert_eq!(h.value("lightAzimuth"), -45.0);
|
||||
h.click(h.rect("Rotation").center());
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "rotation");
|
||||
assert!(!h.rect("Rotation X").is_negative());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_visibility_option_is_saved_resettable_and_leaves_preview_geometry_complete() {
|
||||
let mut h = Harness::new(vec2(1280.0, 1000.0));
|
||||
h.settle();
|
||||
h.click(h.rect("Advanced").center());
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
h.frame(vec![
|
||||
Event::PointerMoved(panel.center()),
|
||||
Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, -220.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
]);
|
||||
h.settle();
|
||||
let before = vectorcraft_effects::revolve_art({
|
||||
let state = h.app.session.doc().unwrap();
|
||||
state.doc.node(state.selection.objects[0]).unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
let checkbox = h.rect("Keep visible surfaces only when expanding");
|
||||
h.click(checkbox.center());
|
||||
assert!(!h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly"));
|
||||
let state = h.app.session.doc().unwrap();
|
||||
let node = state.doc.node(state.selection.objects[0]).unwrap();
|
||||
assert_eq!(node.appearance.effects[0].params["expandVisibleOnly"], json!(false));
|
||||
assert_eq!(vectorcraft_effects::revolve_art(node), Some(before));
|
||||
h.click(h.response(("revolve-reset", "expandVisibleOnly")).center());
|
||||
assert!(h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly"));
|
||||
h.click(h.rect("Keep visible surfaces only when expanding").center());
|
||||
h.click(h.response("revolve-reset-all").center());
|
||||
assert!(h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly"));
|
||||
dialogs::confirm(&mut h.app).unwrap();
|
||||
let state = h.app.session.doc().unwrap();
|
||||
let loaded = vectorcraft_format::load(&vectorcraft_format::save(&state.doc, false)).unwrap();
|
||||
assert_eq!(loaded.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true));
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Pointer gestures exercise the real artboard overlay and command preview, including rollback.
|
||||
use super::*;
|
||||
use crate::{dialogs, theme};
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
struct Harness {
|
||||
app: VectorcraftApp,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":300,"height":300})).unwrap();
|
||||
app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap();
|
||||
app.run("paint.setFill", json!({"color":"#338ad6"})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect":REVOLVE,"item":null})).unwrap();
|
||||
app.canvas_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0)));
|
||||
let v = app.view_mut().unwrap();
|
||||
v.center = Point::new(270.0, 150.0);
|
||||
v.zoom = 2.0;
|
||||
v.fitted = true;
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
let mut h = Self { app, ctx };
|
||||
h.settle();
|
||||
h
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>, modifiers: Modifiers) {
|
||||
let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect();
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0))), events, ..Default::default() };
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
crate::shortcuts::handle(&mut self.app, ui.ctx());
|
||||
egui::CentralPanel::default().show(ui, |ui| crate::canvas::show(&mut self.app, ui));
|
||||
dialogs::show(&mut self.app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn settle(&mut self) {
|
||||
for _ in 0..5 {
|
||||
self.frame(vec![], Modifiers::NONE);
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry(&self) -> Geometry {
|
||||
Geometry::new(&self.app, self.app.ui.dialog.as_ref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn value(&self, key: &str) -> f64 {
|
||||
self.app.ui.dialog.as_ref().unwrap().f64(key, 0.0)
|
||||
}
|
||||
|
||||
fn start(&mut self, p: Pos2, modifiers: Modifiers) {
|
||||
self.frame(vec![Event::PointerMoved(p)], modifiers);
|
||||
self.frame(vec![button(p, true, modifiers)], modifiers);
|
||||
}
|
||||
|
||||
fn release(&mut self, p: Pos2, modifiers: Modifiers) {
|
||||
self.frame(vec![button(p, false, modifiers)], modifiers);
|
||||
self.settle();
|
||||
}
|
||||
|
||||
fn ring_drag(&mut self, axis: usize, modifiers: Modifiers) {
|
||||
let g = self.geometry();
|
||||
let ring = g.rings[axis];
|
||||
// Choose a visible point belonging only to this ring, away from crossings.
|
||||
let phase = (0..SAMPLES)
|
||||
.map(|i| i as f32 * std::f32::consts::TAU / SAMPLES as f32)
|
||||
.find(|a| g.hit(g.center + ring.point(*a)).is_some_and(|h| h.0 == axis) && ring.tangent(*a).length() > 40.0)
|
||||
.unwrap();
|
||||
let from = g.center + ring.point(phase);
|
||||
self.start(from, modifiers);
|
||||
let mut p = from;
|
||||
for i in 1..=8 {
|
||||
p = g.center + ring.point(phase + i as f32 * 0.045);
|
||||
self.frame(vec![Event::PointerMoved(p)], modifiers);
|
||||
}
|
||||
self.release(p, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool, modifiers: Modifiers) -> Event {
|
||||
Event::PointerButton { pos, pressed, button: PointerButton::Primary, modifiers }
|
||||
}
|
||||
fn escape() -> Event {
|
||||
Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_ring_changes_only_its_rotation_field_and_cancel_restores_the_source() {
|
||||
for (axis, key) in KEYS.iter().enumerate() {
|
||||
let mut h = Harness::new();
|
||||
let before = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone();
|
||||
let original = KEYS.map(|key| h.value(key));
|
||||
h.ring_drag(axis, Modifiers::NONE);
|
||||
for (k, key) in KEYS.iter().enumerate() {
|
||||
if k == axis {
|
||||
assert!(h.value(key) > original[k] + 10.0, "axis {axis}, {}", h.value(key));
|
||||
} else {
|
||||
assert_eq!(h.value(key), original[k]);
|
||||
}
|
||||
}
|
||||
let state = h.app.session.active().unwrap();
|
||||
let node = state.doc.node(state.selection.objects[0]).unwrap();
|
||||
assert_eq!(node.path_data(), before.node(node.id).unwrap().path_data(), "ring drags must not move source anchors");
|
||||
assert_eq!(node.appearance.effects[0].params[*key], json!(h.value(key)));
|
||||
dialogs::cancel(&mut h.app);
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_drag_snaps_and_confirm_is_one_undo_step() {
|
||||
let mut h = Harness::new();
|
||||
let before = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone();
|
||||
h.ring_drag(2, Modifiers::SHIFT);
|
||||
assert!(h.value("rotationZ") > 0.0);
|
||||
assert_eq!(h.value("rotationZ") % 15.0, 0.0);
|
||||
dialogs::confirm(&mut h.app).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_orbit_and_light_drag_update_the_canvas_and_escape_restores_only_the_gesture() {
|
||||
let mut h = Harness::new();
|
||||
let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap());
|
||||
let center = h.geometry().center;
|
||||
h.start(center, Modifiers::NONE);
|
||||
let p = center + vec2(50.0, 30.0);
|
||||
h.frame(vec![Event::PointerMoved(p)], Modifiers::NONE);
|
||||
assert_ne!(h.value("rotationX"), before["rotationX"].as_f64().unwrap());
|
||||
assert_ne!(h.value("rotationY"), before["rotationY"].as_f64().unwrap());
|
||||
h.frame(vec![escape()], Modifiers::NONE);
|
||||
h.release(p, Modifiers::NONE);
|
||||
assert!(h.app.ui.dialog.is_some());
|
||||
assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before);
|
||||
let state = h.app.session.active().unwrap();
|
||||
let node = state.doc.node(state.selection.objects[0]).unwrap();
|
||||
assert_eq!(node.appearance.effects[0].params["rotationX"], before["rotationX"]);
|
||||
assert_eq!(node.appearance.effects[0].params["rotationY"], before["rotationY"]);
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light"));
|
||||
h.settle();
|
||||
let light = light_position(h.geometry(), revolve_options(&before));
|
||||
h.start(light, Modifiers::NONE);
|
||||
let p = light + vec2(20.0, 25.0);
|
||||
h.frame(vec![Event::PointerMoved(p)], Modifiers::NONE);
|
||||
h.release(p, Modifiers::NONE);
|
||||
assert_ne!(h.value("lightAzimuth"), -45.0);
|
||||
assert_ne!(h.value("lightElevation"), 45.0);
|
||||
h.frame(vec![escape()], Modifiers::NONE);
|
||||
assert!(h.app.ui.dialog.is_none() && !h.app.session.in_interaction());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_off_has_no_gizmo_and_edge_on_rings_remain_finite() {
|
||||
let mut h = Harness::new();
|
||||
let d = h.app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("rotationX".into(), json!(0.0));
|
||||
d.fields.insert("rotationY".into(), json!(0.0));
|
||||
d.fields.insert("rotationZ".into(), json!(0.0));
|
||||
h.settle();
|
||||
h.ring_drag(0, Modifiers::NONE);
|
||||
assert!(h.value("rotationX").is_finite() && h.value("rotationX").abs() > 1.0);
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false));
|
||||
h.settle();
|
||||
assert!(!h.app.session.in_interaction());
|
||||
let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap());
|
||||
h.ring_drag(2, Modifiers::NONE);
|
||||
assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_rings_adjust_only_their_angle_and_snap_without_rotating_the_object() {
|
||||
for (axis, key) in ["lightAzimuth", "lightElevation"].iter().enumerate() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light"));
|
||||
h.settle();
|
||||
let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap());
|
||||
let from = h.geometry().light_labels()[axis].center();
|
||||
h.start(from, Modifiers::SHIFT);
|
||||
let delta = if axis == 0 { vec2(68.0, 0.0) } else { vec2(0.0, -43.0) };
|
||||
h.frame(vec![Event::PointerMoved(from + delta)], Modifiers::SHIFT);
|
||||
h.release(from + delta, Modifiers::SHIFT);
|
||||
assert_ne!(h.value(key), before[*key].as_f64().unwrap());
|
||||
assert_eq!(h.value(key) % 15.0, 0.0);
|
||||
for unchanged in ["rotationX", "rotationY", "rotationZ", "lightIntensity", "ambient"] {
|
||||
assert_eq!(h.value(unchanged), before[unchanged].as_f64().unwrap());
|
||||
}
|
||||
let other = if axis == 0 { "lightElevation" } else { "lightAzimuth" };
|
||||
assert_eq!(h.value(other), before[other].as_f64().unwrap());
|
||||
let state = h.app.session.active().unwrap();
|
||||
let node = state.doc.node(state.selection.objects[0]).unwrap();
|
||||
assert_eq!(node.appearance.effects[0].params[*key], json!(h.value(key)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_handle_preserves_the_back_side_and_escape_cancel_and_undo_restore_changes() {
|
||||
let mut h = Harness::new();
|
||||
let original = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone();
|
||||
let d = h.app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("__gizmo".into(), json!("light"));
|
||||
d.fields.insert("lightAzimuth".into(), json!(135.0));
|
||||
h.settle();
|
||||
let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap());
|
||||
let light = light_position(h.geometry(), revolve_options(&before));
|
||||
h.start(light, Modifiers::NONE);
|
||||
let to = light + vec2(-15.0, 12.0);
|
||||
h.frame(vec![Event::PointerMoved(to)], Modifiers::NONE);
|
||||
assert!(revolve_options(&super::super::form::params(h.app.ui.dialog.as_ref().unwrap())).light_direction()[2] < 0.0);
|
||||
assert_ne!(h.value("lightElevation"), 45.0);
|
||||
h.frame(vec![escape()], Modifiers::NONE);
|
||||
h.release(to, Modifiers::NONE);
|
||||
assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before);
|
||||
let state = h.app.session.active().unwrap();
|
||||
assert_eq!(state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["lightAzimuth"], before["lightAzimuth"]);
|
||||
// A second gesture is kept by OK, and the whole dialog is one undo step.
|
||||
let from = h.geometry().light_labels()[0].center();
|
||||
h.start(from, Modifiers::NONE);
|
||||
h.frame(vec![Event::PointerMoved(from + vec2(35.0, 0.0))], Modifiers::NONE);
|
||||
h.release(from + vec2(35.0, 0.0), Modifiers::NONE);
|
||||
dialogs::confirm(&mut h.app).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_elevation_is_bounded_and_shading_off_disables_the_light_gizmo() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light"));
|
||||
h.settle();
|
||||
let from = h.geometry().light_labels()[1].center();
|
||||
h.start(from, Modifiers::NONE);
|
||||
h.frame(vec![Event::PointerMoved(from - vec2(0.0, 400.0))], Modifiers::NONE);
|
||||
h.release(from - vec2(0.0, 400.0), Modifiers::NONE);
|
||||
assert_eq!(h.value("lightElevation"), 90.0);
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("shade".into(), json!(false));
|
||||
h.settle();
|
||||
let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap());
|
||||
let from = h.geometry().light_labels()[0].center();
|
||||
h.start(from, Modifiers::NONE);
|
||||
h.frame(vec![Event::PointerMoved(from + vec2(40.0, 0.0))], Modifiers::NONE);
|
||||
h.release(from + vec2(40.0, 0.0), Modifiers::NONE);
|
||||
assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
//! The Variables dialogs (`variable.define`, `variable.bind`, `dataset.new`, `dataset.set`
|
||||
//! and `dataset.select`), opened from Window › Variables or the command palette.
|
||||
//!
|
||||
//! One dialog covers all of them: what it shows is what the command takes. A variable is a
|
||||
//! name and a kind; a dataset is a name and one field per variable — a text field for a
|
||||
//! `text` variable, a checkbox for a `visibility` one. `dataset.set` opens with the row's
|
||||
//! current values, so editing a row is editing what it already says.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{DataValue, VariableKind, Variables};
|
||||
|
||||
use super::{DialogSpec, run_and_close};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
|
||||
/// The dialog kind of every Variables dialog.
|
||||
pub const KIND: &str = "variables";
|
||||
|
||||
/// The kinds a variable can have, as `variable.define` names them.
|
||||
const KINDS: [&str; 2] = ["text", "visibility"];
|
||||
|
||||
/// What the dialog shows for each of [`KINDS`].
|
||||
fn kind_labels() -> [&'static str; 2] {
|
||||
[tl!("Text"), tl!("Visibility")]
|
||||
}
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |d| tl!(&d.str("__label")).to_string(), body, confirm, min_width: 380.0, ..DialogSpec::FORM };
|
||||
|
||||
/// The command the dialog runs and what it is called.
|
||||
fn opened(id: &str) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
"variable.define" => "New Variable",
|
||||
"variable.rename" => "Variable Options",
|
||||
"variable.bind" => "Bind Variable",
|
||||
"dataset.new" => "New Data Set",
|
||||
"dataset.set" => "Edit Data Set",
|
||||
"dataset.rename" => "Rename Data Set",
|
||||
"dataset.select" => "Select Data Set",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The row `id` renames, which the dialog offers as a dropdown when the document has more than
|
||||
/// one. Variable Options renames the one the panel has highlighted, so it starts there.
|
||||
fn rename_of(id: &str) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
"variable.rename" => "variable",
|
||||
"dataset.rename" => "name",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Open `id`'s dialog: OK runs the command with the fields below. `preselect` names the row
|
||||
/// a rename starts on (the panel's highlighted variable, the active dataset); without it, or
|
||||
/// when it no longer exists, the first row — the dialog still offers the dropdown.
|
||||
pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&str>) -> bool {
|
||||
let Some(label) = opened(id) else { return false };
|
||||
let Some(st) = app.session.active() else {
|
||||
app.status(tl!("no document").to_string());
|
||||
return true;
|
||||
};
|
||||
let vars = st.doc.variables.clone();
|
||||
let mut fields = serde_json::Map::new();
|
||||
match id {
|
||||
"variable.define" => {
|
||||
fields.insert("name".into(), json!(""));
|
||||
fields.insert("kind".into(), json!(KINDS[0]));
|
||||
}
|
||||
"variable.bind" => {
|
||||
fields.insert("variable".into(), json!(""));
|
||||
}
|
||||
"dataset.new" => {
|
||||
fields.insert("name".into(), json!(""));
|
||||
value_fields(&vars, vars.datasets.first().map(|d| &d.values), &mut fields);
|
||||
}
|
||||
"dataset.set" => {
|
||||
// Without a row to edit there is nothing to open.
|
||||
let Some(name) = vars.active_dataset.clone().or_else(|| vars.datasets.first().map(|d| d.name.clone())) else {
|
||||
app.status(tl!("no data set to edit").to_string());
|
||||
return true;
|
||||
};
|
||||
let Some(current) = vars.dataset(&name) else { return true };
|
||||
fields.insert("name".into(), json!(name));
|
||||
value_fields(&vars, Some(¤t.values), &mut fields);
|
||||
}
|
||||
"dataset.select" => {
|
||||
fields.insert("name".into(), json!(""));
|
||||
}
|
||||
_ => {
|
||||
// A rename: which row, and its new name.
|
||||
let key = rename_of(id).unwrap_or("name");
|
||||
let names: Vec<&str> = if key == "variable" {
|
||||
vars.variables.iter().map(|v| v.name.as_str()).collect()
|
||||
} else {
|
||||
vars.datasets.iter().map(|d| d.name.as_str()).collect()
|
||||
};
|
||||
if names.is_empty() {
|
||||
app.status(if key == "variable" { tl!("no variable to rename").to_string() } else { tl!("no data set to rename").to_string() });
|
||||
return true;
|
||||
}
|
||||
fields.insert(key.into(), json!(preselect.filter(|p| names.contains(p)).or(names.first().copied()).unwrap_or_default()));
|
||||
fields.insert("newName".into(), json!(""));
|
||||
}
|
||||
}
|
||||
fields.insert("__command".into(), json!(id));
|
||||
fields.insert("__label".into(), json!(label));
|
||||
app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields)));
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether `id` opens one of these dialogs.
|
||||
pub fn opens(id: &str) -> bool {
|
||||
opened(id).is_some()
|
||||
}
|
||||
|
||||
/// One field per variable, prefilled from `current` (the row being edited, else the first row
|
||||
/// so a new one starts from something rather than from nothing).
|
||||
fn value_fields(vars: &Variables, current: Option<&std::collections::BTreeMap<String, DataValue>>, out: &mut serde_json::Map<String, Value>) {
|
||||
for v in &vars.variables {
|
||||
let value = match (current.and_then(|c| c.get(&v.name)), v.kind) {
|
||||
(Some(DataValue::Text(t)), _) => json!(t),
|
||||
(Some(DataValue::Visible(b)), _) => json!(b),
|
||||
// Without a value in the row a text field starts empty (no value in this row) and
|
||||
// a visibility one starts shown: its false is a value, so it is never "unset".
|
||||
(None, VariableKind::Text) => json!(""),
|
||||
(None, VariableKind::Visibility) => json!(true),
|
||||
};
|
||||
out.insert(value_key(&v.name), value);
|
||||
}
|
||||
}
|
||||
|
||||
/// A variable name as a field key. A field key shares the dialog's flat namespace with the
|
||||
/// command's own parameters, so names are prefixed to stay clear of `name` and `values`.
|
||||
fn value_key(name: &str) -> String {
|
||||
format!("value:{name}")
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else {
|
||||
widgets::dim_label(ui, tl!("No document"));
|
||||
return false;
|
||||
};
|
||||
let vars = &st.doc.variables;
|
||||
let variable_names: Vec<&str> = vars.variables.iter().map(|v| v.name.as_str()).collect();
|
||||
let dataset_names: Vec<&str> = vars.datasets.iter().map(|x| x.name.as_str()).collect();
|
||||
egui::Grid::new("variables-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
let label = |ui: &mut egui::Ui, text: &str| widgets::field_label(ui, egui::RichText::new(text).color(t.text));
|
||||
match d.str("__command").as_str() {
|
||||
"variable.define" => {
|
||||
label(ui, tl!("Name"));
|
||||
text(ui, d, "name", 220.0);
|
||||
ui.end_row();
|
||||
label(ui, tl!("Kind"));
|
||||
// The kinds by their translated names; the field keeps the command's own word.
|
||||
let labels = kind_labels();
|
||||
let shown = KINDS.iter().position(|k| *k == d.str("kind")).and_then(|i| labels.get(i)).copied().unwrap_or_default();
|
||||
if let Some(kind) = widgets::dropdown_names(ui, ("variables-dd", "kind"), shown, &labels, 220.0).and_then(|i| KINDS.get(i)) {
|
||||
d.fields.insert("kind".into(), json!(kind));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
"variable.bind" => {
|
||||
label(ui, tl!("Variable"));
|
||||
dropdown(ui, d, "variable", &variable_names, 220.0);
|
||||
ui.end_row();
|
||||
}
|
||||
"dataset.select" => {
|
||||
label(ui, tl!("Data Set"));
|
||||
dropdown(ui, d, "name", &dataset_names, 220.0);
|
||||
ui.end_row();
|
||||
}
|
||||
// A rename: which row, and its new name.
|
||||
"variable.rename" | "dataset.rename" => {
|
||||
let (key, label_text, names) = if d.str("__command") == "variable.rename" {
|
||||
("variable", tl!("Variable").to_string(), &variable_names)
|
||||
} else {
|
||||
("name", tl!("Data Set").to_string(), &dataset_names)
|
||||
};
|
||||
label(ui, &label_text);
|
||||
dropdown(ui, d, key, names, 220.0);
|
||||
ui.end_row();
|
||||
label(ui, tl!("New Name"));
|
||||
text(ui, d, "newName", 220.0);
|
||||
ui.end_row();
|
||||
}
|
||||
_ => {
|
||||
label(ui, tl!("Name"));
|
||||
text(ui, d, "name", 220.0);
|
||||
ui.end_row();
|
||||
if vars.variables.is_empty() {
|
||||
widgets::dim_label(ui, tl!("Define a variable first"));
|
||||
ui.end_row();
|
||||
return;
|
||||
}
|
||||
// One row per variable, in the order they are defined, each with the editor
|
||||
// its kind takes.
|
||||
for v in &vars.variables {
|
||||
label(ui, &v.name);
|
||||
let key = value_key(&v.name);
|
||||
match v.kind {
|
||||
VariableKind::Text => text(ui, d, &key, 220.0),
|
||||
VariableKind::Visibility => checkbox(ui, d, &key),
|
||||
};
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
/// A text field for `key`, written back as it is typed.
|
||||
fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response {
|
||||
let mut s = d.str(key);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width));
|
||||
if r.changed() {
|
||||
d.fields.insert(key.into(), json!(s));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A checkbox for `key`, written back as it is toggled.
|
||||
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) -> egui::Response {
|
||||
let mut b = d.bool(key);
|
||||
let r = ui.checkbox(&mut b, "");
|
||||
if r.changed() {
|
||||
d.fields.insert(key.into(), json!(b));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A dropdown over the document's names `options` (shown as they are, never translated) for
|
||||
/// `key`, written back as it changes. The empty option is first so a fresh dialog can still be
|
||||
/// filled in; a name that is no longer offered reads as empty rather than being silently
|
||||
/// swapped for another one.
|
||||
fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[&str], width: f32) {
|
||||
let cur = d.str(key);
|
||||
let mut labels: Vec<&str> = vec![""];
|
||||
labels.extend_from_slice(options);
|
||||
let shown = if cur.is_empty() || options.contains(&cur.as_str()) { cur.as_str() } else { "" };
|
||||
if let Some(i) = widgets::dropdown_names(ui, ("variables-dd", key), shown, &labels, width) {
|
||||
// The empty option (0) empties the field; the rest are `options`, one along.
|
||||
d.fields.insert(key.into(), json!(i.checked_sub(1).and_then(|i| options.get(i)).copied().unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The `values` object the dialog's fields mean: a text variable whose field is empty carries
|
||||
/// no value in this row, and a visibility checkbox is always a value — its false, too.
|
||||
fn values_of(d: &Dialog, vars: &Variables) -> Value {
|
||||
let mut out = serde_json::Map::new();
|
||||
for v in &vars.variables {
|
||||
let key = value_key(&v.name);
|
||||
if v.kind == VariableKind::Text {
|
||||
let text = d.str(&key);
|
||||
if !text.is_empty() {
|
||||
out.insert(v.name.clone(), json!(text));
|
||||
}
|
||||
} else {
|
||||
out.insert(v.name.clone(), json!(d.bool(&key)));
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let cmd = d.str("__command");
|
||||
let name = d.str("name");
|
||||
let p = match cmd.as_str() {
|
||||
"variable.define" => json!({"name": name, "kind": d.str("kind")}),
|
||||
"variable.bind" => json!({"variable": d.str("variable")}),
|
||||
"variable.rename" => json!({"name": d.str("variable"), "newName": d.str("newName")}),
|
||||
"dataset.rename" => json!({"name": name, "newName": d.str("newName")}),
|
||||
"dataset.new" | "dataset.set" => {
|
||||
let values = app.session.active().map(|st| values_of(d, &st.doc.variables)).unwrap_or_else(|| json!({}));
|
||||
json!({"name": name, "values": values})
|
||||
}
|
||||
"dataset.select" => json!({"name": name}),
|
||||
_ => json!({}),
|
||||
};
|
||||
run_and_close(app, &cmd, p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn app_with_variables() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("variable.define", json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
app.run("variable.define", json!({"name": "Badge", "kind": "visibility"})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defining_a_variable_from_the_dialog_works() {
|
||||
let mut app = app_with_variables();
|
||||
assert!(open(&mut app, "variable.define", None));
|
||||
{
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.kind, KIND);
|
||||
assert_eq!(d.str("name"), "");
|
||||
assert_eq!(d.str("kind"), "text", "text is the default kind");
|
||||
}
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Price"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let vars = app.run("variable.list", json!({})).unwrap();
|
||||
assert!(vars["variables"].as_array().unwrap().iter().any(|v| v["name"] == "Price"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_dataset_carries_the_dialogs_values_and_omits_the_empty_ones() {
|
||||
let mut app = app_with_variables();
|
||||
open(&mut app, "dataset.new", None);
|
||||
{
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("name".into(), json!("Row 1"));
|
||||
d.fields.insert(value_key("Title"), json!("Hello"));
|
||||
d.fields.insert(value_key("Badge"), json!(false));
|
||||
}
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let sets = app.run("dataset.list", json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["name"], "Row 1");
|
||||
assert_eq!(sets["datasets"][0]["values"]["Title"], json!({"text": "Hello"}));
|
||||
assert_eq!(sets["datasets"][0]["values"]["Badge"], json!({"visible": false}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_dataset_opens_with_the_rows_own_values() {
|
||||
let mut app = app_with_variables();
|
||||
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello", "Badge": false}})).unwrap();
|
||||
app.run("dataset.set", json!({"name": "Row 1", "values": {"Badge": true}})).unwrap();
|
||||
open(&mut app, "dataset.set", None);
|
||||
{
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.str("name"), "Row 1");
|
||||
assert_eq!(d.str(&value_key("Title")), "", "the row carries no value for it");
|
||||
assert!(d.bool(&value_key("Badge")), "the row's own value");
|
||||
}
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(value_key("Title"), json!("Changed"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let sets = app.run("dataset.list", json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["values"]["Title"], json!({"text": "Changed"}));
|
||||
assert_eq!(sets["datasets"][0]["values"]["Badge"], json!({"visible": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_a_dataset_from_the_dialog_applies_it() {
|
||||
let mut app = app_with_variables();
|
||||
let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "x"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("variable.bind", json!({"variable": "Title", "ids": [id]})).unwrap();
|
||||
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hi"}})).unwrap();
|
||||
open(&mut app, "dataset.select", None);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Row 1"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.run("text.getRange", json!({"id": id})).unwrap()["text"], "Hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_variables_commands_open_one() {
|
||||
let mut app = app_with_variables();
|
||||
assert!(opens("dataset.set") && opens("variable.define"));
|
||||
assert!(!opens("dataset.list") && !opens("dataset.next"), "no dialog for a query or a step");
|
||||
assert!(!open(&mut app, "dataset.list", None), "and it opens nothing");
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_dialogs_start_on_the_preselected_row() {
|
||||
let mut app = app_with_variables();
|
||||
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello"}})).unwrap();
|
||||
open(&mut app, "variable.rename", Some("Badge"));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("variable"), "Badge");
|
||||
// A preselect the document no longer has falls back to the first row.
|
||||
open(&mut app, "variable.rename", Some("Gone"));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("variable"), "Title");
|
||||
open(&mut app, "dataset.rename", Some("Row 1"));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Row 1");
|
||||
}
|
||||
}
|
||||
@@ -496,12 +496,13 @@ mod tests {
|
||||
|
||||
const SCREEN: Vec2 = vec2(1400.0, 900.0);
|
||||
|
||||
/// Frames of the whole window, 1400 × 900, with pointer input one event a frame (as the
|
||||
/// control channel's `ui.drag` sends it).
|
||||
/// Frames of the whole window, with pointer input one event a frame (as the control
|
||||
/// channel's `ui.drag` sends it).
|
||||
struct Harness {
|
||||
app: VectorcraftApp,
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
screen: Vec2,
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
@@ -510,16 +511,23 @@ mod tests {
|
||||
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
Self::on(SCREEN)
|
||||
}
|
||||
|
||||
/// The same window at another size. The icon column scrolls when it is too short for
|
||||
/// every icon, which hides the ones past the bottom — a test that counts them needs
|
||||
/// a window tall enough to show them all.
|
||||
fn on(screen: Vec2) -> Self {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
let mut h = Self { app, ctx: egui::Context::default(), time: 0.0 };
|
||||
let mut h = Self { app, ctx: egui::Context::default(), time: 0.0, screen };
|
||||
h.settle();
|
||||
h
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) {
|
||||
self.time += 0.05;
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, self.screen);
|
||||
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), events, ..Default::default() };
|
||||
let app = &mut self.app;
|
||||
self.ctx
|
||||
@@ -651,10 +659,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn icons_dragged_out_float_stack_onto_another_group_and_tear_out_of_it() {
|
||||
let mut h = Harness::new();
|
||||
// Tall enough that the icon column shows every icon, so counting them says what it
|
||||
// means: one leaves the column, rather than one more scrolling into view.
|
||||
let mut h = Harness::on(vec2(1400.0, 1600.0));
|
||||
// Color, Color Guide, Swatches… top to bottom.
|
||||
let icons = h.icons();
|
||||
let count = icons.len();
|
||||
assert_eq!(count, crate::state::ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum::<usize>(), "every group's icons are drawn");
|
||||
h.drag(icons[2].center(), pos2(500.0, 250.0));
|
||||
assert_eq!(h.groups(), [["swatches"]]);
|
||||
assert_eq!(h.icons().len(), count - 1, "its icon left the column");
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
Align to Path Zarovnat k cestě
|
||||
Cut Path at Selected Anchor Points Rozdělit cestu ve vybraných kotevních bodech
|
||||
Join Our Discord Připojit se k našemu Discordu
|
||||
Settings… Nastavení…
|
||||
Language Jazyk
|
||||
UI Brightness Jas rozhraní
|
||||
Dark Tmavý
|
||||
@@ -516,6 +515,9 @@
|
||||
Layers Vrstvy
|
||||
Libraries Knihovny
|
||||
Links Propojení
|
||||
{count} linked image(s) changed on disk. Show the new versions? Na disku se změnilo {count} propojených obrázků. Zobrazit nové verze?
|
||||
Update Changed Links Aktualizovat změněná propojení
|
||||
@msg Updated {_1} linked file(s) changed on disk Aktualizováno propojených souborů změněných na disku: {_1}
|
||||
Magic Wand Kouzelná hůlka
|
||||
Navigator Navigátor
|
||||
Pattern Options Možnosti vzorku
|
||||
@@ -532,6 +534,20 @@
|
||||
Paragraph Styles Styly odstavců
|
||||
Tabs Tabulátory
|
||||
Variables Proměnné
|
||||
New Variable… Nová proměnná…
|
||||
Unbind Variable Zrušit přiřazení proměnné
|
||||
Delete Variable Odstranit proměnnou
|
||||
New Data Set… Nová sada dat…
|
||||
Edit Data Set… Upravit sadu dat…
|
||||
Delete Data Set Odstranit sadu dat
|
||||
Next Data Set Další sada dat
|
||||
Previous Data Set Předchozí sada dat
|
||||
Make Text Dynamic Nastavit text jako dynamický
|
||||
Make Visibility Dynamic Nastavit viditelnost jako dynamickou
|
||||
Capture Data Set Pořídit sadu dat
|
||||
Update Data Set Aktualizovat sadu dat
|
||||
Rename Data Set… Přejmenovat sadu dat…
|
||||
Variable Options… Možnosti proměnné…
|
||||
Brush Libraries Knihovny štětců
|
||||
Built-in Libraries Vestavěné knihovny
|
||||
User Defined Uživatelské
|
||||
@@ -670,3 +686,48 @@
|
||||
Patchwork… Patchwork…
|
||||
Stained Glass… Vitráž…
|
||||
Texturizer… Texturovač…
|
||||
System Title Bar Systémové záhlaví okna
|
||||
Exit Ukončit
|
||||
Distort Zkreslení
|
||||
Diffuse Glow… Rozptýlená záře…
|
||||
Glass… Sklo…
|
||||
Ocean Ripple… Vlnky na hladině…
|
||||
Rename Data Set Přejmenovat sadu dat
|
||||
Rename Variable Přejmenovat proměnnou
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Osvětlení
|
||||
Revolve angle Úhel otočení
|
||||
Axis side Strana osy
|
||||
Rotation X Otočení X
|
||||
Rotation Y Otočení Y
|
||||
Rotation Z Otočení Z
|
||||
Shading Stínování
|
||||
Direction Směr
|
||||
Elevation Výška světla
|
||||
Intensity Intenzita
|
||||
Ambient Okolní osvětlení
|
||||
Segments Segmenty
|
||||
Show axis Zobrazit osu
|
||||
Reset all Obnovit vše
|
||||
Reset to default Obnovit výchozí hodnotu
|
||||
Reset all Revolve settings to their defaults. Obnovit výchozí hodnoty všech nastavení rotačního tělesa.
|
||||
Drag values or labels. Double-click a value to type. Upravte hodnoty přetažením čísel nebo popisků. Dvojím kliknutím na hodnotu ji zadáte.
|
||||
Drag rings to rotate. Shift snaps to 15°. Otáčejte přetažením prstenců. Shift nastaví kroky po 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Přetáhněte světlo nebo jeho prstence. Shift nastaví kroky po 15°.
|
||||
lighting Light Světlo
|
||||
Keep visible surfaces only when expanding Při rozšíření ponechat pouze viditelné povrchy
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Při rozšíření vzhledu oříznout zakrytou geometrii. Živý náhled zachová celý povrch. Průhledné výplně a povrchy překračující limity výpočtu viditelnosti zachovají všechny plochy.
|
||||
Left Vlevo
|
||||
Right Vpravo
|
||||
Offset Odsazení
|
||||
Rotation Otočení
|
||||
Cancel Zrušit
|
||||
Accented Edges… Zdůrazněné okraje…
|
||||
Angled Strokes… Šikmé tahy…
|
||||
Crosshatch… Křížové šrafování…
|
||||
Dark Strokes… Tmavé tahy…
|
||||
Ink Outlines… Obrysy tuší…
|
||||
Spatter… Rozstřik…
|
||||
Sprayed Strokes… Stříkané tahy…
|
||||
Sumi-e… Sumi-e…
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
File diff suppressed because it is too large.
Load diff
@@ -1195,6 +1195,7 @@
|
||||
Highlight Alternate Glyphs Resaltar glifos alternativos
|
||||
Highlight anchors on mouse over Resaltar anclas al pasar el puntero
|
||||
Highlight Rows Resaltar filas
|
||||
Highlight Variable Resaltar variable
|
||||
Highlight Substituted Fonts Resaltar fuentes sustituidas
|
||||
Highlight Substituted Glyphs Resaltar glifos sustituidos
|
||||
History Historial
|
||||
@@ -3003,6 +3004,7 @@
|
||||
Untitled Sin título
|
||||
Update Link Actualizar vínculo
|
||||
Update Links Actualizar vínculos
|
||||
Update Changed Links Actualizar vínculos cambiados
|
||||
Update Modified Links Actualizar vínculos modificados
|
||||
Updated {} image(s) Se actualizaron {} imagen(es)
|
||||
UPPERCASE MAYÚSCULAS
|
||||
@@ -3054,6 +3056,38 @@
|
||||
V: V:
|
||||
Variable Width Profile Perfil de anchura variable
|
||||
Variables Variables
|
||||
New Variable Nueva variable
|
||||
Delete Variable Eliminar variable
|
||||
List Variables Enumerar variables
|
||||
Bind Variable Vincular variable
|
||||
Unbind Variable Desvincular variable
|
||||
New Data Set Nuevo conjunto de datos
|
||||
Delete Data Set Eliminar conjunto de datos
|
||||
List Data Sets Enumerar conjuntos de datos
|
||||
Select Data Set Seleccionar conjunto de datos
|
||||
Next Data Set Conjunto de datos siguiente
|
||||
Previous Data Set Conjunto de datos anterior
|
||||
Data Sets Conjuntos de datos
|
||||
New Variable… Nueva variable…
|
||||
New Data Set… Nuevo conjunto de datos…
|
||||
Edit Data Set… Editar conjunto de datos…
|
||||
Data Set Conjunto de datos
|
||||
Kind Tipo
|
||||
Variable Variable
|
||||
no document no hay documento
|
||||
no data set to edit no hay ningún conjunto de datos que editar
|
||||
Make Text Dynamic Hacer texto dinámico
|
||||
Make Visibility Dynamic Hacer visible dinámico
|
||||
Capture Data Set Capturar conjunto de datos
|
||||
Update Data Set Actualizar conjunto de datos
|
||||
Rename Data Set… Renombrar conjunto de datos…
|
||||
Variable Options… Opciones de variable…
|
||||
Select Bound Object Seleccionar objeto vinculado
|
||||
Select All Bound Objects Seleccionar todos los objetos vinculados
|
||||
Multiple objects Varios objetos
|
||||
New Name Nuevo nombre
|
||||
no variable to rename no hay ninguna variable que renombrar
|
||||
no data set to rename no hay ningún conjunto de datos que renombrar
|
||||
Variation: Variación:
|
||||
Vector Effects Efectos vectoriales
|
||||
Vector Halftone Semitono vectorial
|
||||
@@ -3138,6 +3172,7 @@
|
||||
{count} linked file(s) not found No se encontraron {count} archivo(s) vinculado(s)
|
||||
fonts not copied: {fonts} fuentes no copiadas: {fonts}
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? {count} imagen(es) vinculada(s) cambiaron desde que se guardó este documento. ¿Mostrar las versiones nuevas?
|
||||
{count} linked image(s) changed on disk. Show the new versions? {count} imagen(es) vinculada(s) cambiaron en el disco. ¿Mostrar las versiones nuevas?
|
||||
{count} more rules: Export writes every one. {count} reglas más: Exportar las escribe todas.
|
||||
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Se omitieron {count} objetos sin nombre: ponles nombre o activa Objetos sin nombre en el menú del panel.
|
||||
{effect} is already applied {effect} ya está aplicado
|
||||
@@ -3788,6 +3823,7 @@ axis Both Ambos
|
||||
@msg {_1} is already used by {_2} {_1} ya lo usa {_2}
|
||||
@msg {_1} linked file(s) can't be read here: their previews show aquí no se pueden leer {_1} archivo(s) vinculado(s): se muestran sus previsualizaciones
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagen(es) vinculada(s) cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas
|
||||
@msg Updated {_1} linked file(s) changed on disk Se actualizaron {_1} archivo(s) vinculado(s) que cambiaron en el disco
|
||||
@msg {_1}: not saved, so not closed {_1}: no se guardó, así que no se cerró
|
||||
@msg {bpp}-bit bitmaps can't be read no se pueden leer mapas de bits de {bpp} bits
|
||||
@msg {command} didn't create an object {command} no creó ningún objeto
|
||||
@@ -3971,3 +4007,88 @@ axis Both Ambos
|
||||
Top Left Arriba a la izquierda
|
||||
Top Right Arriba a la derecha
|
||||
Bottom Right Abajo a la derecha
|
||||
Edge Borde
|
||||
System Title Bar Barra de título del sistema
|
||||
Value Axis Eje de valores
|
||||
On Left Side En el lado izquierdo
|
||||
On Right Side En el lado derecho
|
||||
On Both Sides En ambos lados
|
||||
Short Corta
|
||||
Full Width Ancho completo
|
||||
Distort Distorsionar
|
||||
Diffuse Glow… Resplandor difuso…
|
||||
Glass… Vidrio…
|
||||
Ocean Ripple… Rizo oceánico…
|
||||
Diffuse Glow Resplandor difuso
|
||||
Glass Vidrio
|
||||
Ocean Ripple Rizo oceánico
|
||||
Graininess Granulosidad
|
||||
Glow Amount Cantidad de resplandor
|
||||
Clear Amount Cantidad de claridad
|
||||
Ripple Size Tamaño de rizo
|
||||
Ripple Magnitude Magnitud de rizo
|
||||
Blocks Bloques
|
||||
Frosted Esmerilado
|
||||
Tiny Lens Lente pequeña
|
||||
Rename Data Set Renombrar conjunto de datos
|
||||
Rename Variable Renombrar variable
|
||||
Variable Options Opciones de variable
|
||||
Edit Data Set Editar conjunto de datos
|
||||
Define a variable first Defina primero una variable
|
||||
Text Texto
|
||||
Visibility Visibilidad
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Iluminación
|
||||
Revolve angle Ángulo de revolución
|
||||
Axis side Lado del eje
|
||||
Rotation X Rotación X
|
||||
Rotation Y Rotación Y
|
||||
Rotation Z Rotación Z
|
||||
Shading Sombreado
|
||||
Direction Dirección
|
||||
Elevation Elevación
|
||||
Ambient Luz ambiental
|
||||
Segments Segmentos
|
||||
Show axis Mostrar eje
|
||||
Reset all Restablecer todo
|
||||
Reset to default Restablecer valor predeterminado
|
||||
Reset all Revolve settings to their defaults. Restablece todos los ajustes de revolución a sus valores predeterminados.
|
||||
Drag values or labels. Double-click a value to type. Arrastra los valores o las etiquetas. Haz doble clic en un valor para escribir.
|
||||
Drag rings to rotate. Shift snaps to 15°. Arrastra los anillos para girar. Shift ajusta a pasos de 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Arrastra la luz o sus anillos. Shift ajusta a pasos de 15°.
|
||||
lighting Light Luz
|
||||
Keep visible surfaces only when expanding Conservar solo superficies visibles al expandir
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Recorta la geometría oculta al expandir la apariencia. La vista previa conserva toda la superficie. Las pinturas transparentes y las superficies que superan los límites de visibilidad conservan todas las caras.
|
||||
Accented Edges… Bordes acentuados…
|
||||
Angled Strokes… Pinceladas en ángulo…
|
||||
Crosshatch… Sombreado cruzado…
|
||||
Dark Strokes… Pinceladas oscuras…
|
||||
Ink Outlines… Contornos de tinta…
|
||||
Spatter… Salpicadura…
|
||||
Sprayed Strokes… Pinceladas pulverizadas…
|
||||
Sumi-e… Sumi-e…
|
||||
Accented Edges Bordes acentuados
|
||||
Angled Strokes Pinceladas en ángulo
|
||||
Crosshatch Sombreado cruzado
|
||||
Dark Strokes Pinceladas oscuras
|
||||
Ink Outlines Contornos de tinta
|
||||
Spatter Salpicadura
|
||||
Sprayed Strokes Pinceladas pulverizadas
|
||||
Sumi-e Sumi-e
|
||||
Direction Balance Equilibrio de dirección
|
||||
Stroke Length Longitud de pincelada
|
||||
Sharpness Nitidez
|
||||
Strength Fuerza
|
||||
Balance Equilibrio
|
||||
Black Intensity Intensidad del negro
|
||||
White Intensity Intensidad del blanco
|
||||
Dark Intensity Intensidad oscura
|
||||
Spray Radius Radio de pulverización
|
||||
Stroke Direction Dirección de pincelada
|
||||
Stroke Width Anchura de pincelada
|
||||
Stroke Pressure Presión de pincelada
|
||||
Right Diagonal Diagonal derecha
|
||||
Left Diagonal Diagonal izquierda
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1194,6 +1194,7 @@
|
||||
Highlight Alternate Glyphs Surligner les glyphes de substitution
|
||||
Highlight anchors on mouse over Mettre en évidence les points d’ancrage au survol
|
||||
Highlight Rows Surligner les lignes
|
||||
Highlight Variable Surligner la variable
|
||||
Highlight Substituted Fonts Surligner les polices remplacées
|
||||
Highlight Substituted Glyphs Surligner les glyphes remplacés
|
||||
History Historique
|
||||
@@ -3000,6 +3001,7 @@
|
||||
Untitled Sans titre
|
||||
Update Link Mettre à jour le lien
|
||||
Update Links Mettre à jour les liens
|
||||
Update Changed Links Mettre à jour les liens modifiés
|
||||
Update Modified Links Mettre à jour les liens modifiés
|
||||
Updated {} image(s) {} image(s) mise(s) à jour
|
||||
UPPERCASE MAJUSCULES
|
||||
@@ -3051,6 +3053,38 @@
|
||||
V: V :
|
||||
Variable Width Profile Profil de largeur variable
|
||||
Variables Variables
|
||||
New Variable Nouvelle variable
|
||||
Delete Variable Supprimer la variable
|
||||
List Variables Lister les variables
|
||||
Bind Variable Lier la variable
|
||||
Unbind Variable Délier la variable
|
||||
New Data Set Nouveau jeu de données
|
||||
Delete Data Set Supprimer le jeu de données
|
||||
List Data Sets Lister les jeux de données
|
||||
Select Data Set Sélectionner le jeu de données
|
||||
Next Data Set Jeu de données suivant
|
||||
Previous Data Set Jeu de données précédent
|
||||
Data Sets Jeux de données
|
||||
New Variable… Nouvelle variable…
|
||||
New Data Set… Nouveau jeu de données…
|
||||
Edit Data Set… Modifier le jeu de données…
|
||||
Data Set Jeu de données
|
||||
Kind Type
|
||||
Variable Variable
|
||||
no document aucun document
|
||||
no data set to edit aucun jeu de données à modifier
|
||||
Make Text Dynamic Rendre le texte dynamique
|
||||
Make Visibility Dynamic Rendre la visibilité dynamique
|
||||
Capture Data Set Capturer le jeu de données
|
||||
Update Data Set Mettre à jour le jeu de données
|
||||
Rename Data Set… Renommer le jeu de données…
|
||||
Variable Options… Options de variable…
|
||||
Select Bound Object Sélectionner l’objet lié
|
||||
Select All Bound Objects Sélectionner tous les objets liés
|
||||
Multiple objects Plusieurs objets
|
||||
New Name Nouveau nom
|
||||
no variable to rename aucune variable à renommer
|
||||
no data set to rename aucun jeu de données à renommer
|
||||
Variation: Variation :
|
||||
Vector Effects Effets vectoriels
|
||||
Vector Halftone Trame de demi-teintes vectorielle
|
||||
@@ -3135,6 +3169,7 @@
|
||||
{count} linked file(s) not found {count} fichier(s) lié(s) introuvable(s)
|
||||
fonts not copied: {fonts} polices non copiées : {fonts}
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? {count} image(s) liée(s) modifiée(s) depuis l’enregistrement de ce document. Afficher les nouvelles versions ?
|
||||
{count} linked image(s) changed on disk. Show the new versions? {count} image(s) liée(s) modifiée(s) sur le disque. Afficher les nouvelles versions ?
|
||||
{count} more rules: Export writes every one. {count} règles de plus : Exporter les écrit toutes.
|
||||
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objets sans nom omis : nommez-les ou activez Objets sans nom dans le menu du panneau.
|
||||
{effect} is already applied {effect} est déjà appliqué
|
||||
@@ -3785,6 +3820,7 @@ axis Both Les deux
|
||||
@msg {_1} is already used by {_2} {_1} est déjà utilisé par {_2}
|
||||
@msg {_1} linked file(s) can't be read here: their previews show {_1} fichier(s) lié(s) illisible(s) ici : leurs aperçus sont affichés
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} image(s) liée(s) modifiée(s) sur le disque : Mettre à jour les liens affiche les nouvelles versions
|
||||
@msg Updated {_1} linked file(s) changed on disk {_1} fichier(s) lié(s) modifié(s) sur le disque mis à jour
|
||||
@msg {_1}: not saved, so not closed {_1} : non enregistré, donc non fermé
|
||||
@msg {bpp}-bit bitmaps can't be read impossible de lire les images bitmap en {bpp} bits
|
||||
@msg {command} didn't create an object {command} n’a créé aucun objet
|
||||
@@ -3983,3 +4019,86 @@ axis Both Les deux
|
||||
Top Right En haut à droite
|
||||
Bottom Right En bas à droite
|
||||
effect Soft Doux
|
||||
Edge Bord
|
||||
System Title Bar Barre de titre du système
|
||||
Value Axis Axe des valeurs
|
||||
On Left Side À gauche
|
||||
On Right Side À droite
|
||||
On Both Sides Des deux côtés
|
||||
Short Courte
|
||||
Full Width Toute la largeur
|
||||
Distort Déformation
|
||||
Diffuse Glow… Diffusion lumineuse…
|
||||
Glass… Verre…
|
||||
Ocean Ripple… Ondulation océanique…
|
||||
Diffuse Glow Diffusion lumineuse
|
||||
Glass Verre
|
||||
Ocean Ripple Ondulation océanique
|
||||
Graininess Granularité
|
||||
Glow Amount Intensité de la lueur
|
||||
Clear Amount Zone claire
|
||||
Ripple Size Taille de l'ondulation
|
||||
Ripple Magnitude Amplitude de l'ondulation
|
||||
Blocks Blocs
|
||||
Frosted Dépoli
|
||||
Tiny Lens Petites lentilles
|
||||
Rename Data Set Renommer le jeu de données
|
||||
Rename Variable Renommer la variable
|
||||
Variable Options Options de variable
|
||||
Edit Data Set Modifier le jeu de données
|
||||
Define a variable first Définissez d’abord une variable
|
||||
Text Texte
|
||||
Visibility Visibilité
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Éclairage
|
||||
Revolve angle Angle de révolution
|
||||
Axis side Côté de l’axe
|
||||
Rotation X Rotation X
|
||||
Rotation Y Rotation Y
|
||||
Rotation Z Rotation Z
|
||||
Shading Ombrage
|
||||
Direction Direction
|
||||
Elevation Élévation
|
||||
Ambient Lumière ambiante
|
||||
Segments Segments
|
||||
Show axis Afficher l’axe
|
||||
Reset all Tout réinitialiser
|
||||
Reset to default Valeur par défaut
|
||||
Reset all Revolve settings to their defaults. Rétablir les valeurs par défaut de tous les réglages de révolution.
|
||||
Drag values or labels. Double-click a value to type. Faites glisser les valeurs ou les libellés. Double-cliquez sur une valeur pour la saisir.
|
||||
Drag rings to rotate. Shift snaps to 15°. Faites glisser les anneaux pour tourner. Maj contraint par pas de 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Faites glisser la lumière ou ses anneaux. Maj contraint par pas de 15°.
|
||||
Keep visible surfaces only when expanding Conserver uniquement les surfaces visibles lors de la décomposition
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Découpe la géométrie masquée lors de la décomposition de l’aspect. L’aperçu conserve toute la surface. Les peintures transparentes et les surfaces dépassant les limites de visibilité conservent toutes les faces.
|
||||
lighting Light Lumière
|
||||
Accented Edges… Contours accentués…
|
||||
Angled Strokes… Traits obliques…
|
||||
Crosshatch… Hachures croisées…
|
||||
Dark Strokes… Traits sombres…
|
||||
Ink Outlines… Contours à l'encre…
|
||||
Spatter… Éclaboussures…
|
||||
Sprayed Strokes… Traits pulvérisés…
|
||||
Sumi-e… Sumi-e…
|
||||
Accented Edges Contours accentués
|
||||
Angled Strokes Traits obliques
|
||||
Crosshatch Hachures croisées
|
||||
Dark Strokes Traits sombres
|
||||
Ink Outlines Contours à l'encre
|
||||
Spatter Éclaboussures
|
||||
Sprayed Strokes Traits pulvérisés
|
||||
Sumi-e Sumi-e
|
||||
Direction Balance Équilibre de direction
|
||||
Stroke Length Longueur des traits
|
||||
Sharpness Netteté
|
||||
Strength Force
|
||||
Balance Équilibre
|
||||
Black Intensity Intensité du noir
|
||||
White Intensity Intensité du blanc
|
||||
Dark Intensity Intensité sombre
|
||||
Spray Radius Rayon de pulvérisation
|
||||
Stroke Direction Direction des traits
|
||||
Stroke Width Largeur des traits
|
||||
Stroke Pressure Pression des traits
|
||||
Right Diagonal Diagonale droite
|
||||
Left Diagonal Diagonale gauche
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1194,6 +1194,7 @@
|
||||
Highlight Alternate Glyphs Evidenzia glifi alternativi
|
||||
Highlight anchors on mouse over Evidenzia ancoraggi al passaggio del mouse
|
||||
Highlight Rows Evidenzia righe
|
||||
Highlight Variable Evidenzia variabile
|
||||
Highlight Substituted Fonts Evidenzia font sostituiti
|
||||
Highlight Substituted Glyphs Evidenzia glifi sostituiti
|
||||
History Cronologia
|
||||
@@ -3003,6 +3004,7 @@
|
||||
Untitled Senza titolo
|
||||
Update Link Aggiorna collegamento
|
||||
Update Links Aggiorna collegamenti
|
||||
Update Changed Links Aggiorna collegamenti modificati
|
||||
Update Modified Links Aggiorna collegamenti modificati
|
||||
Updated {} image(s) Immagini aggiornate: {}
|
||||
UPPERCASE MAIUSCOLO
|
||||
@@ -3054,6 +3056,38 @@
|
||||
V: V:
|
||||
Variable Width Profile Profilo larghezza variabile
|
||||
Variables Variabili
|
||||
New Variable Nuova variabile
|
||||
Delete Variable Elimina variabile
|
||||
List Variables Elenca variabili
|
||||
Bind Variable Collega variabile
|
||||
Unbind Variable Scollega variabile
|
||||
New Data Set Nuovo set di dati
|
||||
Delete Data Set Elimina set di dati
|
||||
List Data Sets Elenca set di dati
|
||||
Select Data Set Seleziona set di dati
|
||||
Next Data Set Set di dati successivo
|
||||
Previous Data Set Set di dati precedente
|
||||
Data Sets Set di dati
|
||||
New Variable… Nuova variabile…
|
||||
New Data Set… Nuovo set di dati…
|
||||
Edit Data Set… Modifica set di dati…
|
||||
Data Set Set di dati
|
||||
Kind Tipo
|
||||
Variable Variabile
|
||||
no document nessun documento
|
||||
no data set to edit nessun set di dati da modificare
|
||||
Make Text Dynamic Rendi testo dinamico
|
||||
Make Visibility Dynamic Rendi visibilità dinamica
|
||||
Capture Data Set Cattura set di dati
|
||||
Update Data Set Aggiorna set di dati
|
||||
Rename Data Set… Rinomina set di dati…
|
||||
Variable Options… Opzioni variabile…
|
||||
Select Bound Object Seleziona oggetto associato
|
||||
Select All Bound Objects Seleziona tutti gli oggetti associati
|
||||
Multiple objects Più oggetti
|
||||
New Name Novo nome
|
||||
no variable to rename nessuna variabile da rinominare
|
||||
no data set to rename nessun set di dati da rinominare
|
||||
Variation: Variazione:
|
||||
Vector Effects Effetti vettoriali
|
||||
Vector Halftone Retino vettoriale
|
||||
@@ -3138,6 +3172,7 @@
|
||||
{count} linked file(s) not found File collegati non trovati: {count}
|
||||
fonts not copied: {fonts} font non copiati: {fonts}
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? Immagini collegate modificate dopo il salvataggio di questo documento: {count}. Vuoi mostrare le nuove versioni?
|
||||
{count} linked image(s) changed on disk. Show the new versions? Immagini collegate modificate su disco: {count}. Vuoi mostrare le nuove versioni?
|
||||
{count} more rules: Export writes every one. Altre {count} regole: Esporta le scrive tutte.
|
||||
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} oggetti senza nome esclusi: assegna loro un nome o attiva Oggetti senza nome nel menu del pannello.
|
||||
{effect} is already applied L'effetto {effect} è già applicato
|
||||
@@ -3788,6 +3823,7 @@ axis Both Entrambi
|
||||
@msg {_1} is already used by {_2} {_1} è già usato da {_2}
|
||||
@msg {_1} linked file(s) can't be read here: their previews show file collegati non leggibili qui: {_1}; vengono mostrate le loro anteprime
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions immagini collegate modificate su disco: {_1}; Aggiorna collegamenti mostra le nuove versioni
|
||||
@msg Updated {_1} linked file(s) changed on disk File collegati modificati su disco aggiornati: {_1}
|
||||
@msg {_1}: not saved, so not closed {_1}: non salvato, quindi non chiuso
|
||||
@msg {bpp}-bit bitmaps can't be read impossibile leggere bitmap a {bpp} bit
|
||||
@msg {command} didn't create an object {command} non ha creato alcun oggetto
|
||||
@@ -3973,3 +4009,88 @@ axis Both Entrambi
|
||||
Bottom Right In basso a destra
|
||||
effect Soft Morbido
|
||||
effect Regular Normale
|
||||
Edge Bordo
|
||||
System Title Bar Barra del titolo di sistema
|
||||
Value Axis Asse dei valori
|
||||
On Left Side Sul lato sinistro
|
||||
On Right Side Sul lato destro
|
||||
On Both Sides Su entrambi i lati
|
||||
Short Breve
|
||||
Full Width Larghezza intera
|
||||
Distort Distorsione
|
||||
Diffuse Glow… Bagliore diffuso…
|
||||
Glass… Vetro…
|
||||
Ocean Ripple… Increspatura oceanica…
|
||||
Diffuse Glow Bagliore diffuso
|
||||
Glass Vetro
|
||||
Ocean Ripple Increspatura oceanica
|
||||
Graininess Granulosità
|
||||
Glow Amount Quantità di bagliore
|
||||
Clear Amount Quantità di chiarezza
|
||||
Ripple Size Dimensione increspatura
|
||||
Ripple Magnitude Ampiezza increspatura
|
||||
Blocks Blocchi
|
||||
Frosted Smerigliato
|
||||
Tiny Lens Lente piccola
|
||||
Rename Data Set Rinomina set di dati
|
||||
Rename Variable Rinomina variabile
|
||||
Variable Options Opzioni variabile
|
||||
Edit Data Set Modifica set di dati
|
||||
Define a variable first Definisci prima una variabile
|
||||
Text Testo
|
||||
Visibility Visibilità
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Illuminazione
|
||||
Revolve angle Angolo di rivoluzione
|
||||
Axis side Lato dell’asse
|
||||
Rotation X Rotazione X
|
||||
Rotation Y Rotazione Y
|
||||
Rotation Z Rotazione Z
|
||||
Shading Ombreggiatura
|
||||
Direction Direzione
|
||||
Elevation Elevazione
|
||||
Ambient Luce ambientale
|
||||
Segments Segmenti
|
||||
Show axis Mostra asse
|
||||
Reset all Ripristina tutto
|
||||
Reset to default Ripristina valore predefinito
|
||||
Reset all Revolve settings to their defaults. Ripristina tutte le impostazioni di rivoluzione ai valori predefiniti.
|
||||
Drag values or labels. Double-click a value to type. Trascina i valori o le etichette. Fai doppio clic su un valore per digitarlo.
|
||||
Drag rings to rotate. Shift snaps to 15°. Trascina gli anelli per ruotare. Shift usa incrementi di 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Trascina la luce o i suoi anelli. Shift usa incrementi di 15°.
|
||||
lighting Light Luce
|
||||
Keep visible surfaces only when expanding Mantieni solo le superfici visibili durante l’espansione
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Ritaglia la geometria coperta durante Espandi aspetto. L’anteprima mantiene la superficie completa. Le tinte trasparenti e le superfici che superano i limiti di visibilità mantengono tutte le facce.
|
||||
Accented Edges… Bordi accentuati…
|
||||
Angled Strokes… Pennellate angolate…
|
||||
Crosshatch… Tratteggio incrociato…
|
||||
Dark Strokes… Pennellate scure…
|
||||
Ink Outlines… Contorni a china…
|
||||
Spatter… Spruzzo…
|
||||
Sprayed Strokes… Pennellate spruzzate…
|
||||
Sumi-e… Sumi-e…
|
||||
Accented Edges Bordi accentuati
|
||||
Angled Strokes Pennellate angolate
|
||||
Crosshatch Tratteggio incrociato
|
||||
Dark Strokes Pennellate scure
|
||||
Ink Outlines Contorni a china
|
||||
Spatter Spruzzo
|
||||
Sprayed Strokes Pennellate spruzzate
|
||||
Sumi-e Sumi-e
|
||||
Direction Balance Bilanciamento direzione
|
||||
Stroke Length Lunghezza pennellata
|
||||
Sharpness Nitidezza
|
||||
Strength Forza
|
||||
Balance Bilanciamento
|
||||
Black Intensity Intensità nero
|
||||
White Intensity Intensità bianco
|
||||
Dark Intensity Intensità scuri
|
||||
Spray Radius Raggio spruzzo
|
||||
Stroke Direction Direzione pennellata
|
||||
Stroke Width Larghezza pennellata
|
||||
Stroke Pressure Pressione pennellata
|
||||
Right Diagonal Diagonale destra
|
||||
Left Diagonal Diagonale sinistra
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -569,6 +569,39 @@
|
||||
Paragraph Styles 段落スタイル
|
||||
Tabs タブ
|
||||
Variables 変数
|
||||
New Variable 新規変数
|
||||
Delete Variable 変数を削除
|
||||
List Variables 変数一覧
|
||||
Bind Variable 変数をバインド
|
||||
Unbind Variable 変数のバインドを解除
|
||||
New Data Set 新規データセット
|
||||
Delete Data Set データセットを削除
|
||||
List Data Sets データセット一覧
|
||||
Select Data Set データセットを選択
|
||||
Next Data Set 次のデータセット
|
||||
Previous Data Set 前のデータセット
|
||||
Data Sets データセット
|
||||
New Variable… 新規変数…
|
||||
New Data Set… 新規データセット…
|
||||
Edit Data Set… データセットを編集…
|
||||
Data Set データセット
|
||||
Kind 種類
|
||||
Variable 変数
|
||||
no document ドキュメントがありません
|
||||
no data set to edit 編集するデータセットがありません
|
||||
Make Text Dynamic テキストを動的にする
|
||||
Make Visibility Dynamic 表示を動的にする
|
||||
Capture Data Set データセットを取得
|
||||
Update Data Set データセットを更新
|
||||
Rename Data Set… データセットの名前を変更…
|
||||
Variable Options… 変数のオプション…
|
||||
Select Bound Object バインドされたオブジェクトを選択
|
||||
Select All Bound Objects すべてのバインドされたオブジェクトを選択
|
||||
Multiple objects 複数のオブジェクト
|
||||
New Name 新しい名前
|
||||
Rename Variable 変数の名前を変更
|
||||
no variable to rename 名前を変更する変数がありません
|
||||
no data set to rename 名前を変更するデータセットがありません
|
||||
Brush Libraries ブラシライブラリ
|
||||
Built-in Libraries 内蔵ライブラリ
|
||||
User Defined ユーザー定義
|
||||
@@ -1415,6 +1448,7 @@
|
||||
High Performance (discrete) 高パフォーマンス (専用 GPU)
|
||||
Highlight Alternate Glyphs 代替字形をハイライト
|
||||
Highlight Rows 行をハイライト
|
||||
Highlight Variable 変数をハイライト表示
|
||||
Highlight Substituted Fonts 置換フォントをハイライト
|
||||
Highlight Substituted Glyphs 置換字形をハイライト
|
||||
Highlight anchors on mouse over マウスオーバー時にアンカーをハイライト
|
||||
@@ -2668,6 +2702,7 @@
|
||||
Unshift シフトを戻す
|
||||
Update Link リンクを更新
|
||||
Update Links リンクを更新
|
||||
Update Changed Links 変更されたリンクを更新
|
||||
Update Modified Links 変更されたリンクを更新
|
||||
Use メニュー
|
||||
Use Artboards アートボードを使用
|
||||
@@ -2777,6 +2812,7 @@
|
||||
{count} linked file(s) not found リンクファイルが {count} 個見つかりません
|
||||
fonts not copied: {fonts} コピーされなかったフォント:{fonts}
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? 保存後に {count} 個のリンク画像が変更されています。新しいバージョンを表示しますか?
|
||||
{count} linked image(s) changed on disk. Show the new versions? {count} 個のリンク画像がディスク上で変更されました。新しいバージョンを表示しますか?
|
||||
{count} more rules: Export writes every one. ほかに {count} 個のルールがあります。書き出しにはすべて含まれます。
|
||||
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 名前のないオブジェクト {count} 個を除外しました。名前を付けるか、パネルメニューで「名前のないオブジェクト」をオンにしてください。
|
||||
{effect} is already applied {effect} はすでに適用されています
|
||||
@@ -3719,6 +3755,7 @@ theme Appearance 外観
|
||||
@msg `{tool}` isn't a Liquify tool `{tool}` はゆがみツールではありません
|
||||
@msg {_1} linked file(s) can't be read here: their previews show この環境では {_1} 個のリンクファイルを読み込めません: 代わりにプレビューを表示しています
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} 個のリンク画像がディスク上で変更されました: 「リンクを更新」で新しいバージョンを表示できます
|
||||
@msg Updated {_1} linked file(s) changed on disk ディスク上で変更された {_1} 個のリンクファイルを更新しました
|
||||
@msg pick a colour, gradient or pattern to save as a swatch スウォッチとして保存するカラー、グラデーション、またはパターンを選択してください
|
||||
@msg Packaged {n} file(s) in {folder}{note} {n} 個のファイルを {folder} にパッケージしました{note}
|
||||
@msg Packaged {n} file(s){note} {n} 個のファイルをパッケージしました{note}
|
||||
@@ -3966,3 +4003,87 @@ theme Appearance 外観
|
||||
effect Soft ソフト
|
||||
effect Horizontal 水平
|
||||
effect Vertical 垂直
|
||||
Edge エッジ
|
||||
System Title Bar システムのタイトルバー
|
||||
Value Axis 数値軸
|
||||
On Left Side 左側
|
||||
On Right Side 右側
|
||||
On Both Sides 両側
|
||||
Short 短い
|
||||
Full Width 全幅
|
||||
Distort 変形
|
||||
Diffuse Glow… 光彩拡散…
|
||||
Glass… ガラス…
|
||||
Ocean Ripple… 海の波紋…
|
||||
Diffuse Glow 光彩拡散
|
||||
Glass ガラス
|
||||
Ocean Ripple 海の波紋
|
||||
Graininess 粒子
|
||||
Glow Amount 光彩の量
|
||||
Clear Amount クリア量
|
||||
Ripple Size 波紋のサイズ
|
||||
Ripple Magnitude 波紋の強さ
|
||||
Blocks ブロック
|
||||
Frosted すりガラス
|
||||
Tiny Lens 小さなレンズ
|
||||
Rename Data Set データセットの名前を変更
|
||||
Variable Options 変数のオプション
|
||||
Edit Data Set データセットを編集
|
||||
Define a variable first 先に変数を定義してください
|
||||
Text テキスト
|
||||
Visibility 表示状態
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting 照明
|
||||
Revolve angle 回転体の角度
|
||||
Axis side 軸の位置
|
||||
Rotation X X 軸回転
|
||||
Rotation Y Y 軸回転
|
||||
Rotation Z Z 軸回転
|
||||
Shading 陰影
|
||||
Direction 方向
|
||||
Elevation 仰角
|
||||
Ambient 環境光
|
||||
Segments 分割数
|
||||
Show axis 軸を表示
|
||||
Reset all すべてリセット
|
||||
Reset to default 初期値に戻す
|
||||
Reset all Revolve settings to their defaults. 回転体のすべての設定を初期値に戻します。
|
||||
Drag values or labels. Double-click a value to type. 数値やラベルをドラッグして調整。数値をダブルクリックすると入力できます。
|
||||
Drag rings to rotate. Shift snaps to 15°. リングをドラッグして回転。Shiftで15°刻みに調整。
|
||||
Drag the light, or its rings. Shift snaps to 15°. ライトやリングをドラッグ。Shiftで15°刻みに調整。
|
||||
lighting Light ライト
|
||||
Keep visible surfaces only when expanding アピアランス分割時に見える面だけを保持
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. アピアランスの分割時に隠れた部分を除去します。プレビューでは面全体を保持します。透明なペイントや可視性処理の制限を超える面は、すべての面を保持します。
|
||||
Accented Edges… エッジの強調…
|
||||
Angled Strokes… 斜めのストローク…
|
||||
Crosshatch… クロスハッチ…
|
||||
Dark Strokes… 暗いストローク…
|
||||
Ink Outlines… インクの輪郭…
|
||||
Spatter… はね…
|
||||
Sprayed Strokes… スプレーストローク…
|
||||
Sumi-e… 墨絵…
|
||||
Accented Edges エッジの強調
|
||||
Angled Strokes 斜めのストローク
|
||||
Crosshatch クロスハッチ
|
||||
Dark Strokes 暗いストローク
|
||||
Ink Outlines インクの輪郭
|
||||
Spatter はね
|
||||
Sprayed Strokes スプレーストローク
|
||||
Sumi-e 墨絵
|
||||
Direction Balance 方向のバランス
|
||||
Stroke Length ストロークの長さ
|
||||
Sharpness シャープさ
|
||||
Strength 強度
|
||||
Balance バランス
|
||||
Black Intensity 黒の強さ
|
||||
White Intensity 白の強さ
|
||||
Dark Intensity 暗部の強さ
|
||||
Spray Radius スプレー半径
|
||||
Stroke Direction ストロークの方向
|
||||
Stroke Width ストロークの幅
|
||||
Stroke Pressure ストロークの筆圧
|
||||
Right Diagonal 右斜め
|
||||
Left Diagonal 左斜め
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -82,13 +82,23 @@ fn plural_east_slavic(n: u64) -> usize {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 11] = [
|
||||
pub static LANGUAGES: [LangInfo; 12] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Japanese: the whole interface (every menu string and `tl!` literal) and the status and error
|
||||
// messages, keeping the product, workspace and perspective preset names in English (`MENU_KEEP_AS_IS`).
|
||||
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Czech: every menu label (`menu_catalogs_translate_every_menu_label`); panels and dialogs not yet.
|
||||
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_czech, complete_menus: false, catalog: OnceLock::new() },
|
||||
// German: the whole interface and the status and error messages, keeping the same names in
|
||||
// English as Spanish; every `de-*` locale (`de-DE`, `de-AT`, `de-CH`, `de-LU` …) resolves here.
|
||||
LangInfo {
|
||||
code: "de",
|
||||
name: "Deutsch",
|
||||
source: include_str!("de.tsv"),
|
||||
plural: plural_one_other,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
// Spanish: the whole interface in neutral, international Spanish, keeping the same names in
|
||||
// English as Japanese; every `es-*` locale (`es-ES`, `es-MX`, `es-AR`, `es-419` …) resolves here.
|
||||
LangInfo {
|
||||
|
||||
@@ -2119,7 +2119,6 @@
|
||||
Set Zoom Definir Zoom
|
||||
Set base color to the current color Definir cor base para a cor atual
|
||||
Set the selected color to the current color Definir a cor selecionada para a cor atual
|
||||
Settings… Configurações…
|
||||
Setup… Configuração…
|
||||
Shadows and Glows Sombras e Brilhos
|
||||
Shape Forma
|
||||
@@ -2483,6 +2482,9 @@
|
||||
Unshift Desfazer Mudança de Cor
|
||||
Update Link Atualizar Link
|
||||
Update Links Atualizar Links
|
||||
Update Changed Links Atualizar Links alterados
|
||||
{count} linked image(s) changed on disk. Show the new versions? {count} imagem(ns) vinculada(s) alterada(s) no disco. Mostrar as novas versões?
|
||||
@msg Updated {_1} linked file(s) changed on disk {_1} arquivo(s) vinculado(s) alterado(s) no disco atualizado(s)
|
||||
Update Modified Links Atualizar Links Modificados
|
||||
Use Use
|
||||
Use Artboards Usar Mesas de Trabalho
|
||||
@@ -2528,6 +2530,20 @@
|
||||
V: V:
|
||||
Variable Width Profile Perfil de Largura Variável
|
||||
Variables Variáveis
|
||||
New Variable… Nova variável…
|
||||
Unbind Variable Desassociar variável
|
||||
Delete Variable Excluir variável
|
||||
New Data Set… Novo conjunto de dados…
|
||||
Edit Data Set… Editar conjunto de dados…
|
||||
Delete Data Set Excluir conjunto de dados
|
||||
Next Data Set Próximo conjunto de dados
|
||||
Previous Data Set Conjunto de dados anterior
|
||||
Make Text Dynamic Tornar texto dinâmico
|
||||
Make Visibility Dynamic Tornar visibilidade dinâmica
|
||||
Capture Data Set Capturar conjunto de dados
|
||||
Update Data Set Atualizar conjunto de dados
|
||||
Rename Data Set… Renomear conjunto de dados…
|
||||
Variable Options… Opções de variável…
|
||||
Variation: Variação:
|
||||
Vector Effects Efeitos Vetoriais
|
||||
Vector Halftone Meio-tom Vetorial
|
||||
@@ -2730,3 +2746,46 @@
|
||||
Patchwork… Colcha de retalhos…
|
||||
Stained Glass… Vitral…
|
||||
Texturizer… Texturizador…
|
||||
System Title Bar Barra de título do sistema
|
||||
Exit Sair
|
||||
Distort Distorcer
|
||||
Diffuse Glow… Brilho difuso…
|
||||
Glass… Vidro…
|
||||
Ocean Ripple… Ondulação oceânica…
|
||||
Rename Data Set Renomear conjunto de dados
|
||||
Rename Variable Renomear variável
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Iluminação
|
||||
Revolve angle Ângulo de giro
|
||||
Axis side Lado do eixo
|
||||
Rotation X Rotação X
|
||||
Rotation Y Rotação Y
|
||||
Rotation Z Rotação Z
|
||||
Shading Sombreamento
|
||||
Direction Direção
|
||||
Elevation Elevação
|
||||
Intensity Intensidade
|
||||
Ambient Luz ambiente
|
||||
Segments Segmentos
|
||||
Show axis Mostrar eixo
|
||||
Reset all Redefinir tudo
|
||||
Reset to default Restaurar padrão
|
||||
Reset all Revolve settings to their defaults. Restaurar os valores padrão de todas as configurações de revolução.
|
||||
Drag values or labels. Double-click a value to type. Arraste os valores ou rótulos. Clique duas vezes em um valor para digitá-lo.
|
||||
Drag rings to rotate. Shift snaps to 15°. Arraste os anéis para girar. Shift ajusta em incrementos de 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Arraste a luz ou seus anéis. Shift ajusta em incrementos de 15°.
|
||||
lighting Light Luz
|
||||
Keep visible surfaces only when expanding Manter apenas as superfícies visíveis ao expandir
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Recortar a geometria coberta ao expandir a aparência. A visualização ao vivo mantém toda a superfície. Pinturas transparentes e superfícies que excedem os limites de visibilidade mantêm todas as faces.
|
||||
Left Esquerda
|
||||
Right Direita
|
||||
Offset Deslocamento
|
||||
Accented Edges… Bordas Acentuadas…
|
||||
Angled Strokes… Traços Angulares…
|
||||
Crosshatch… Hachura Cruzada…
|
||||
Dark Strokes… Traços Escuros…
|
||||
Ink Outlines… Contornos a Tinta…
|
||||
Spatter… Respingo…
|
||||
Sprayed Strokes… Traços Borrifados…
|
||||
Sumi-e… Sumi-e…
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1192,6 +1192,7 @@
|
||||
Highlight Alternate Glyphs Выделять альтернативные глифы
|
||||
Highlight anchors on mouse over Выделять опорные точки при наведении
|
||||
Highlight Rows Выделять строки
|
||||
Highlight Variable Подсветить переменную
|
||||
Highlight Substituted Fonts Выделять заменённые шрифты
|
||||
Highlight Substituted Glyphs Выделять заменённые глифы
|
||||
History История
|
||||
@@ -2999,6 +3000,7 @@
|
||||
Untitled Без названия
|
||||
Update Link Обновить связь
|
||||
Update Links Обновить связи
|
||||
Update Changed Links Обновить измененные связи
|
||||
Update Modified Links Обновить изменённые связи
|
||||
Updated {} image(s) Обновлено изображений: {}
|
||||
UPPERCASE ПРОПИСНЫЕ
|
||||
@@ -3050,6 +3052,38 @@
|
||||
V: В:
|
||||
Variable Width Profile Профиль переменной ширины
|
||||
Variables Переменные
|
||||
New Variable Новая переменная
|
||||
Delete Variable Удалить переменную
|
||||
List Variables Перечислить переменные
|
||||
Bind Variable Привязать переменную
|
||||
Unbind Variable Отвязать переменную
|
||||
New Data Set Новый набор данных
|
||||
Delete Data Set Удалить набор данных
|
||||
List Data Sets Перечислить наборы данных
|
||||
Select Data Set Выделить набор данных
|
||||
Next Data Set Следующий набор данных
|
||||
Previous Data Set Предыдущий набор данных
|
||||
Data Sets Наборы данных
|
||||
New Variable… Новая переменная…
|
||||
New Data Set… Новый набор данных…
|
||||
Edit Data Set… Изменить набор данных…
|
||||
Data Set Набор данных
|
||||
Kind Тип
|
||||
Variable Переменная
|
||||
no document нет документа
|
||||
no data set to edit нет набора данных для изменения
|
||||
Make Text Dynamic Сделать текст динамическим
|
||||
Make Visibility Dynamic Сделать видимость динамической
|
||||
Capture Data Set Записать набор данных
|
||||
Update Data Set Обновить набор данных
|
||||
Rename Data Set… Переименовать набор данных…
|
||||
Variable Options… Параметры переменной…
|
||||
Select Bound Object Выбрать связанный объект
|
||||
Select All Bound Objects Выбрать все связанные объекты
|
||||
Multiple objects Несколько объектов
|
||||
New Name Новое имя
|
||||
no variable to rename нет переменной для переименования
|
||||
no data set to rename нет набора данных для переименования
|
||||
Variation: Вариация:
|
||||
Vector Effects Векторные эффекты
|
||||
Vector Halftone Векторный полутон
|
||||
@@ -3134,6 +3168,7 @@
|
||||
{count} linked file(s) not found Не найдено связанных файлов: {count}
|
||||
fonts not copied: {fonts} шрифты не скопированы: {fonts}
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? Связанных изображений ({count}) изменились с момента сохранения этого документа. Показать новые версии?
|
||||
{count} linked image(s) changed on disk. Show the new versions? Связанных изображений ({count}) изменились на диске. Показать новые версии?
|
||||
{count} more rules: Export writes every one. Ещё правил: {count}: экспорт записывает все.
|
||||
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Пропущено объектов без имени: {count}: назовите их или включите «Объекты без имени» в меню панели.
|
||||
{effect} is already applied {effect} уже применён
|
||||
@@ -3784,6 +3819,7 @@ axis Both Оба
|
||||
@msg {_1} is already used by {_2} {_1} уже используется {_2}
|
||||
@msg {_1} linked file(s) can't be read here: their previews show здесь невозможно прочитать связанных файлов: {_1}: отображаются их предварительные просмотры
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions связанных изображений ({_1}) изменились на диске: «Обновить связи» покажет новые версии
|
||||
@msg Updated {_1} linked file(s) changed on disk Обновлено связанных файлов, измененных на диске: {_1}
|
||||
@msg {_1}: not saved, so not closed {_1}: не сохранён, поэтому не закрыт
|
||||
@msg {bpp}-bit bitmaps can't be read растровые изображения с глубиной {bpp} бит не читаются
|
||||
@msg {command} didn't create an object {command} не создал объект
|
||||
@@ -3969,3 +4005,88 @@ axis Both Оба
|
||||
Top Left Сверху слева
|
||||
Top Right Сверху справа
|
||||
Bottom Right Снизу справа
|
||||
Edge Край
|
||||
System Title Bar Системная строка заголовка
|
||||
Value Axis Ось значений
|
||||
On Left Side Слева
|
||||
On Right Side Справа
|
||||
On Both Sides С обеих сторон
|
||||
Short Короткие
|
||||
Full Width Во всю ширину
|
||||
Distort Искажение
|
||||
Diffuse Glow… Рассеянное свечение…
|
||||
Glass… Стекло…
|
||||
Ocean Ripple… Океанские волны…
|
||||
Diffuse Glow Рассеянное свечение
|
||||
Glass Стекло
|
||||
Ocean Ripple Океанские волны
|
||||
Graininess Зернистость
|
||||
Glow Amount Сила свечения
|
||||
Clear Amount Чистые области
|
||||
Ripple Size Размер волн
|
||||
Ripple Magnitude Сила волн
|
||||
Blocks Блоки
|
||||
Frosted Матовое
|
||||
Tiny Lens Мелкие линзы
|
||||
Rename Data Set Переименовать набор данных
|
||||
Rename Variable Переименовать переменную
|
||||
Variable Options Параметры переменной
|
||||
Edit Data Set Изменить набор данных
|
||||
Define a variable first Сначала определите переменную
|
||||
Text Текст
|
||||
Visibility Видимость
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Освещение
|
||||
Revolve angle Угол вращения профиля
|
||||
Axis side Сторона оси
|
||||
Rotation X Поворот X
|
||||
Rotation Y Поворот Y
|
||||
Rotation Z Поворот Z
|
||||
Shading Затенение
|
||||
Direction Направление
|
||||
Elevation Высота
|
||||
Ambient Фоновое освещение
|
||||
Segments Сегменты
|
||||
Show axis Показать ось
|
||||
Reset all Сбросить всё
|
||||
Reset to default Вернуть значение по умолчанию
|
||||
Reset all Revolve settings to their defaults. Возвращает все настройки вращения профиля к значениям по умолчанию.
|
||||
Drag values or labels. Double-click a value to type. Перетаскивайте значения или подписи. Для ввода дважды щёлкните значение.
|
||||
Drag rings to rotate. Shift snaps to 15°. Перетаскивайте кольца для вращения. Shift задаёт шаг 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Перетаскивайте источник света или кольца. Shift задаёт шаг 15°.
|
||||
lighting Light Свет
|
||||
Keep visible surfaces only when expanding При разборе сохранять только видимые поверхности
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Удаляет скрытую геометрию при разборе оформления. Предпросмотр сохраняет всю поверхность. Прозрачные краски и поверхности, превышающие ограничения видимости, сохраняют все грани.
|
||||
Accented Edges… Акцент на краях…
|
||||
Angled Strokes… Наклонные мазки…
|
||||
Crosshatch… Перекрёстная штриховка…
|
||||
Dark Strokes… Тёмные мазки…
|
||||
Ink Outlines… Контуры тушью…
|
||||
Spatter… Разбрызгивание…
|
||||
Sprayed Strokes… Распылённые мазки…
|
||||
Sumi-e… Суми-э…
|
||||
Accented Edges Акцент на краях
|
||||
Angled Strokes Наклонные мазки
|
||||
Crosshatch Перекрёстная штриховка
|
||||
Dark Strokes Тёмные мазки
|
||||
Ink Outlines Контуры тушью
|
||||
Spatter Разбрызгивание
|
||||
Sprayed Strokes Распылённые мазки
|
||||
Sumi-e Суми-э
|
||||
Direction Balance Баланс направления
|
||||
Stroke Length Длина мазка
|
||||
Sharpness Резкость
|
||||
Strength Сила
|
||||
Balance Баланс
|
||||
Black Intensity Интенсивность чёрного
|
||||
White Intensity Интенсивность белого
|
||||
Dark Intensity Интенсивность тёмного
|
||||
Spray Radius Радиус распыления
|
||||
Stroke Direction Направление мазков
|
||||
Stroke Width Ширина мазка
|
||||
Stroke Pressure Нажим мазка
|
||||
Right Diagonal Диагональ вправо
|
||||
Left Diagonal Диагональ влево
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -8,7 +8,11 @@ fn tags_map_to_languages() {
|
||||
assert_eq!(lang_from_tag("en_US.UTF-8"), Some(Lang::EN));
|
||||
assert_eq!(lang_from_tag("C"), Some(Lang::EN));
|
||||
assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN));
|
||||
assert_eq!(lang_from_tag("de_DE"), None);
|
||||
assert_eq!(lang_from_tag("nl_NL"), None);
|
||||
// German: Germany, Austria, Switzerland and the rest share the one catalog.
|
||||
for tag in ["de", "de_DE.UTF-8", "de-AT", "de_CH", "de-LU", "de_DE.UTF-8@euro"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(de()), "{tag}");
|
||||
}
|
||||
assert_eq!(lang_from_tag("ja_JP.UTF-8"), Lang::from_code("ja"));
|
||||
assert_eq!(lang_from_tag("ja"), Lang::from_code("ja"));
|
||||
// Spanish: every region (and Latin America as a whole) resolves to the one catalog.
|
||||
@@ -51,7 +55,8 @@ fn candidates_walk_from_specific_to_general() {
|
||||
#[test]
|
||||
fn os_language_lists_are_parsed() {
|
||||
assert_eq!(first_supported("(\n \"zh-Hant-TW\",\n \"en-US\"\n)\n"), Some(ZH()));
|
||||
assert_eq!(first_supported("(\n \"de-DE\",\n \"en-US\"\n)\n"), Some(Lang::EN));
|
||||
assert_eq!(first_supported("(\n \"nl-NL\",\n \"en-US\"\n)\n"), Some(Lang::EN));
|
||||
assert_eq!(first_supported("(\n \"de-DE\",\n \"en-US\"\n)\n"), Some(de()));
|
||||
assert_eq!(first_supported("(\n \"fr-FR\",\n \"en-US\"\n)\n"), Lang::from_code("fr"));
|
||||
assert_eq!(first_supported("("), None);
|
||||
// Windows: `reg query HKCU\Control Panel\International /v LocaleName`.
|
||||
@@ -199,8 +204,8 @@ fn the_engine_follows_the_interface_language() {
|
||||
assert_eq!(app.ui_language().code(), "ja");
|
||||
}
|
||||
|
||||
/// VectorCraft › Language (`app.language`) sets the `interfaceLanguage` preference, which is what
|
||||
/// persists; the checked item follows the preference, and a bad code is an error, not a change.
|
||||
/// `app.language` sets the `interfaceLanguage` preference, which is what persists; the checked
|
||||
/// item follows the preference, and a bad code is an error, not a change.
|
||||
#[test]
|
||||
fn language_command_sets_the_preference() {
|
||||
use crate::menus::{checked, run_ui_command};
|
||||
@@ -218,9 +223,21 @@ fn language_command_sets_the_preference() {
|
||||
run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "auto"})).unwrap().unwrap();
|
||||
assert_eq!(app.session.prefs.interface_language, "auto");
|
||||
assert_eq!(checked(&app, "app.language", &serde_json::json!({"lang": "auto"})), Some(true));
|
||||
// Every registered language is in the menu, by its own name.
|
||||
let labels: Vec<String> =
|
||||
crate::menus::menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some("app.language")).map(|e| e.label).collect();
|
||||
// Every registered language is in the Mac App menu's Language, by its own name (the in-window
|
||||
// bar has no Language menu).
|
||||
let layout = crate::native_menu::mac_layout(&app, &crate::native_menu::from_tree(&app, &crate::menus::menu_tree()), Lang::EN);
|
||||
let app_menu = layout.bar.menus.into_iter().find(|m| m.title == "VectorCraft").expect("App menu");
|
||||
fn items(nodes: &[crate::native_menu::Node], out: &mut Vec<String>) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
crate::native_menu::Node::Item(it) if it.command == Some("app.language") => out.push(it.label.clone()),
|
||||
crate::native_menu::Node::Submenu { children, .. } => items(children, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut labels = vec![];
|
||||
items(&app_menu.children, &mut labels);
|
||||
for l in Lang::all() {
|
||||
assert!(labels.iter().any(|x| x == l.name()), "{} missing from {labels:?}", l.code());
|
||||
}
|
||||
@@ -423,9 +440,9 @@ fn cs() -> Lang {
|
||||
}
|
||||
|
||||
/// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English.
|
||||
const KEEPS_MENU_NAMES: [&str; 8] = ["cs", "es", "fr", "it", "ja", "pt-br", "ru", "uk"];
|
||||
const KEEPS_MENU_NAMES: [&str; 9] = ["cs", "de", "es", "fr", "it", "ja", "pt-br", "ru", "uk"];
|
||||
|
||||
/// Menu labels the menu-complete catalogs (Czech, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace
|
||||
/// Menu labels the menu-complete catalogs (Czech, German, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace
|
||||
/// names and the perspective grid presets (names, shown untranslated wherever else they appear).
|
||||
/// Each language's own name in the Language menu is left alone too.
|
||||
const MENU_KEEP_AS_IS: &[&str] = &[
|
||||
@@ -515,7 +532,7 @@ fn toggled_labels() -> Vec<String> {
|
||||
labels
|
||||
}
|
||||
|
||||
/// Czech, Spanish, Italian, Japanese and Brazilian Portuguese cover every menu label, the Show/Hide pairs
|
||||
/// Czech, German, Spanish, Italian, Japanese and Brazilian Portuguese cover every menu label, the Show/Hide pairs
|
||||
/// and the canvas context menu included (panels and dialogs not yet).
|
||||
#[test]
|
||||
fn menu_catalogs_translate_every_menu_label() {
|
||||
@@ -553,9 +570,14 @@ fn menu_catalogs_translate_every_menu_label() {
|
||||
}
|
||||
assert_eq!(tr(Lang::from_code("pt-br").expect("pt-br"), "File"), "Arquivo");
|
||||
assert_eq!(tr(es(), "File"), "Archivo");
|
||||
assert_eq!(tr(de(), "File"), "Datei");
|
||||
assert_eq!(tr(it(), "Edit"), "Modifica");
|
||||
}
|
||||
|
||||
fn de() -> Lang {
|
||||
Lang::from_code("de").expect("de registered")
|
||||
}
|
||||
|
||||
fn es() -> Lang {
|
||||
Lang::from_code("es").expect("es registered")
|
||||
}
|
||||
@@ -597,6 +619,36 @@ fn spanish_reads_as_spanish() {
|
||||
assert_eq!(trn(es(), 3, "{n} Layer", "{n} Layers"), "3 capas");
|
||||
}
|
||||
|
||||
/// German uses the vector-illustration vocabulary its users know, has two plural forms like
|
||||
/// English (zero takes the plural), translates the reason inside a message too, and tells the
|
||||
/// interface theme ("Erscheinungsbild") from the Appearance panel ("Aussehen").
|
||||
#[test]
|
||||
fn german_reads_as_german() {
|
||||
for (en, want) in [
|
||||
("Artboard Tool", "Zeichenflächen-Werkzeug"),
|
||||
("Swatches", "Farbfelder"),
|
||||
("Pathfinder", "Pathfinder"),
|
||||
("Stroke", "Kontur"),
|
||||
("Fill", "Fläche"),
|
||||
("Direct Selection Tool", "Direktauswahl-Werkzeug"),
|
||||
("Save As…", "Speichern unter…"),
|
||||
("Undo", "Rückgängig"),
|
||||
] {
|
||||
assert_eq!(tr(de(), en), want);
|
||||
}
|
||||
assert_eq!(trn(de(), 1, "{n} Layer", "{n} Layers"), "1 Ebene");
|
||||
assert_eq!(trn(de(), 0, "{n} Layer", "{n} Layers"), "0 Ebenen");
|
||||
assert_eq!(trn(de(), 3, "{n} Layer", "{n} Layers"), "3 Ebenen");
|
||||
assert_eq!(tr_ctx(de(), "axis", "Both"), "Beide");
|
||||
assert_eq!(tr(de(), "Appearance"), "Aussehen");
|
||||
assert_eq!(tr_ctx(de(), "theme", "Appearance"), "Erscheinungsbild");
|
||||
let nested = "command `object.group` is not available right now: nothing selected";
|
||||
assert_eq!(message(de(), nested), "der Befehl `object.group` ist derzeit nicht verfügbar: nichts ausgewählt");
|
||||
// Text styles are "…format" in German: the kind is joined to it ("Neues Zeichenformat erstellen").
|
||||
assert_eq!(fmt(tr(de(), "Create New {kind} Style"), &[("kind", tr(de(), "Character"))]), "Neues Zeichenformat erstellen");
|
||||
assert_eq!(fmt(tr(de(), "Show {kind} Brushes"), &[("kind", tr(de(), "Calligraphic"))]), "Kalligrafiepinsel einblenden");
|
||||
}
|
||||
|
||||
/// French uses the vector-illustration vocabulary its users know, puts zero in the singular (« 0
|
||||
/// calque »), and reads the same in the menus and in the panels.
|
||||
#[test]
|
||||
@@ -757,7 +809,7 @@ fn russian_plurals_have_three_forms() {
|
||||
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 0, 1, 2, 2, 0]);
|
||||
}
|
||||
|
||||
/// Czech, Spanish, French and Italian letters (and the punctuation their text uses) come from each family's own first font, not
|
||||
/// Czech, German, Spanish, French and Italian letters (and the punctuation their text uses) come from each family's own first font, not
|
||||
/// from a fallback further down the stack. (`has_glyph` can't tell: it counts characters of the
|
||||
/// face that draws missing glyphs, the first one, as missing.)
|
||||
#[test]
|
||||
@@ -772,7 +824,7 @@ fn latin_script_glyphs_are_available_without_system_fonts() {
|
||||
let first = first.unwrap();
|
||||
let mut font = fonts.fonts.font(&family);
|
||||
let chars = font.characters();
|
||||
for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’âêîôûçëïÿœæÂÊÎÔÛÇËÏŒÆ\u{a0}".chars()
|
||||
for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’âêîôûçëïÿœæÂÊÎÔÛÇËÏŒÆäöÄÖß\u{a0}".chars()
|
||||
{
|
||||
assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}");
|
||||
}
|
||||
@@ -872,7 +924,7 @@ fn complete_languages_translate_every_message() {
|
||||
}
|
||||
|
||||
/// Languages whose catalogs cover every status and error message.
|
||||
const COMPLETE_MESSAGES: &[&str] = &["es", "fr", "it", "ja", "ru", "uk"];
|
||||
const COMPLETE_MESSAGES: &[&str] = &["de", "es", "fr", "it", "ja", "ru", "uk"];
|
||||
|
||||
/// Crates whose error and status messages reach the status bar.
|
||||
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"];
|
||||
|
||||
@@ -1188,6 +1188,7 @@
|
||||
Highlight Alternate Glyphs Виділяти альтернативні гліфи
|
||||
Highlight anchors on mouse over Виділяти опорні точки при наведенні
|
||||
Highlight Rows Виділяти рядки
|
||||
Highlight Variable Підсвітити змінну
|
||||
Highlight Substituted Fonts Виділяти замінені шрифти
|
||||
Highlight Substituted Glyphs Виділяти замінені гліфи
|
||||
History Історія
|
||||
@@ -2995,6 +2996,7 @@
|
||||
Untitled Без назви
|
||||
Update Link Оновити зв’язок
|
||||
Update Links Оновити зв’язки
|
||||
Update Changed Links Оновити змінені зв’язки
|
||||
Update Modified Links Оновити змінені зв’язки
|
||||
Updated {} image(s) Оновлено зображень: {}
|
||||
UPPERCASE ВЕЛИКІ ЛІТЕРИ
|
||||
@@ -3046,6 +3048,38 @@
|
||||
V: В:
|
||||
Variable Width Profile Профіль змінної ширини
|
||||
Variables Змінні
|
||||
New Variable Нова змінна
|
||||
Delete Variable Видалити змінну
|
||||
List Variables Перерахувати змінні
|
||||
Bind Variable Прив'язати змінну
|
||||
Unbind Variable Відв'язати змінну
|
||||
New Data Set Новий набір даних
|
||||
Delete Data Set Видалити набір даних
|
||||
List Data Sets Перерахувати набори даних
|
||||
Select Data Set Виділити набір даних
|
||||
Next Data Set Наступний набір даних
|
||||
Previous Data Set Попередній набір даних
|
||||
Data Sets Набори даних
|
||||
New Variable… Нова змінна…
|
||||
New Data Set… Новий набір даних…
|
||||
Edit Data Set… Змінити набір даних…
|
||||
Data Set Набір даних
|
||||
Kind Тип
|
||||
Variable Змінна
|
||||
no document немає документа
|
||||
no data set to edit немає набору даних для зміни
|
||||
Make Text Dynamic Зробити текст динамічним
|
||||
Make Visibility Dynamic Зробити видимість динамічною
|
||||
Capture Data Set Записати набір даних
|
||||
Update Data Set Оновити набір даних
|
||||
Rename Data Set… Перейменувати набір даних…
|
||||
Variable Options… Параметри змінної…
|
||||
Select Bound Object Вибрати пов’язаний об’єкт
|
||||
Select All Bound Objects Вибрати всі пов’язані об’єкти
|
||||
Multiple objects Кілька об’єктів
|
||||
New Name Нова назва
|
||||
no variable to rename немає змінної для перейменування
|
||||
no data set to rename немає набору даних для перейменування
|
||||
Variation: Варіація:
|
||||
Vector Effects Векторні ефекти
|
||||
Vector Halftone Векторний напівтон
|
||||
@@ -3130,6 +3164,7 @@
|
||||
{count} linked file(s) not found не знайдено пов’язаних файлів: {count}
|
||||
fonts not copied: {fonts} шрифти не скопійовано: {fonts}
|
||||
{count} linked image(s) changed since this document was saved. Show the new versions? пов’язані зображення ({count}) змінилися з моменту збереження цього документа. Показати нові версії?
|
||||
{count} linked image(s) changed on disk. Show the new versions? пов’язані зображення ({count}) змінилися на диску. Показати нові версії?
|
||||
{count} more rules: Export writes every one. ще правил: {count}: експорт записує все.
|
||||
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. пропущено об’єкти без імені: {count}: назвіть їх або увімкніть «Об’єкти без імені» у меню панелі.
|
||||
{effect} is already applied {effect} вже застосований
|
||||
@@ -3771,6 +3806,7 @@ axis Both Обидві
|
||||
@msg {_1} is already used by {_2} {_1} вже використовується {_2}
|
||||
@msg {_1} linked file(s) can't be read here: their previews show тут неможливо прочитати пов’язані файли: {_1}: відображаються їх попередні перегляди
|
||||
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions пов’язаних зображень ({_1}) змінилися на диску: «Оновити зв’язки» покаже нові версії
|
||||
@msg Updated {_1} linked file(s) changed on disk Оновлено пов’язаних файлів, змінених на диску: {_1}
|
||||
@msg {_1}: not saved, so not closed {_1}: не збережено, тому не закрито
|
||||
@msg {bpp}-bit bitmaps can't be read растрові зображення з глибиною {bpp} біт не читаються
|
||||
@msg {command} didn't create an object {command} не створив об’єкт
|
||||
@@ -3954,3 +3990,86 @@ axis Both Обидві
|
||||
Top Left Вгорі ліворуч
|
||||
Top Right Вгорі праворуч
|
||||
Bottom Right Знизу праворуч
|
||||
Edge Край
|
||||
System Title Bar Системний рядок заголовка
|
||||
Value Axis Вісь значень
|
||||
On Left Side Ліворуч
|
||||
On Right Side Праворуч
|
||||
On Both Sides З обох боків
|
||||
Short Короткі
|
||||
Full Width На всю ширину
|
||||
Distort Спотворення
|
||||
Diffuse Glow… Розсіяне світіння…
|
||||
Glass… Скло…
|
||||
Ocean Ripple… Океанські хвилі…
|
||||
Diffuse Glow Розсіяне світіння
|
||||
Glass Скло
|
||||
Ocean Ripple Океанські хвилі
|
||||
Graininess Зернистість
|
||||
Glow Amount Сила світіння
|
||||
Clear Amount Чисті ділянки
|
||||
Ripple Size Розмір хвиль
|
||||
Ripple Magnitude Сила хвиль
|
||||
Blocks Блоки
|
||||
Frosted Матове
|
||||
Tiny Lens Дрібні лінзи
|
||||
Rename Data Set Перейменувати набір даних
|
||||
Rename Variable Перейменувати змінну
|
||||
Variable Options Параметри змінної
|
||||
Edit Data Set Змінити набір даних
|
||||
Define a variable first Спочатку визначте змінну
|
||||
Text Текст
|
||||
Visibility Видимість
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Освітлення
|
||||
Revolve angle Кут обертання
|
||||
Axis side Бік осі
|
||||
Rotation X Обертання X
|
||||
Rotation Y Обертання Y
|
||||
Rotation Z Обертання Z
|
||||
Shading Затінення
|
||||
Direction Напрямок
|
||||
Elevation Висота
|
||||
Ambient Навколишнє освітлення
|
||||
Segments Сегменти
|
||||
Show axis Показати вісь
|
||||
Reset all Скинути все
|
||||
Reset to default Відновити типове значення
|
||||
Reset all Revolve settings to their defaults. Відновити типові значення всіх параметрів обертання.
|
||||
Drag values or labels. Double-click a value to type. Перетягуйте значення або підписи. Двічі клацніть значення, щоб увести його.
|
||||
Drag rings to rotate. Shift snaps to 15°. Перетягуйте кільця для обертання. Shift задає крок 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Перетягуйте джерело світла або його кільця. Shift задає крок 15°.
|
||||
Keep visible surfaces only when expanding Під час розгортання зберігати лише видимі поверхні
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Обрізає приховану геометрію під час розгортання оформлення. Попередній перегляд зберігає всю поверхню. Прозорі заливки та поверхні, що перевищують обмеження видимості, зберігають усі грані.
|
||||
lighting Light Світло
|
||||
Accented Edges… Акцент на краях…
|
||||
Angled Strokes… Похилі мазки…
|
||||
Crosshatch… Перехресне штрихування…
|
||||
Dark Strokes… Темні мазки…
|
||||
Ink Outlines… Контури тушшю…
|
||||
Spatter… Розбризкування…
|
||||
Sprayed Strokes… Розпилені мазки…
|
||||
Sumi-e… Сумі-е…
|
||||
Accented Edges Акцент на краях
|
||||
Angled Strokes Похилі мазки
|
||||
Crosshatch Перехресне штрихування
|
||||
Dark Strokes Темні мазки
|
||||
Ink Outlines Контури тушшю
|
||||
Spatter Розбризкування
|
||||
Sprayed Strokes Розпилені мазки
|
||||
Sumi-e Сумі-е
|
||||
Direction Balance Баланс напрямку
|
||||
Stroke Length Довжина мазка
|
||||
Sharpness Різкість
|
||||
Strength Сила
|
||||
Balance Баланс
|
||||
Black Intensity Інтенсивність чорного
|
||||
White Intensity Інтенсивність білого
|
||||
Dark Intensity Інтенсивність темного
|
||||
Spray Radius Радіус розпилення
|
||||
Stroke Direction Напрямок мазків
|
||||
Stroke Width Ширина мазка
|
||||
Stroke Pressure Натиск мазка
|
||||
Right Diagonal Діагональ праворуч
|
||||
Left Diagonal Діагональ ліворуч
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -2893,6 +2893,9 @@
|
||||
Untitled 未命名
|
||||
Update Link 更新链接
|
||||
Update Links 更新链接
|
||||
Update Changed Links 更新已更改的链接
|
||||
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 个链接图像已在磁盘上更改。要显示新版本吗?
|
||||
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 个在磁盘上更改的链接文件
|
||||
Update Modified Links 更新已修改的链接
|
||||
Updated {} image(s) 已更新 {} 张图像
|
||||
UPPERCASE 大写
|
||||
@@ -2944,6 +2947,38 @@
|
||||
V: V:
|
||||
Variable Width Profile 变量宽度配置文件
|
||||
Variables 变量
|
||||
New Variable 新建变量
|
||||
Delete Variable 删除变量
|
||||
List Variables 列出变量
|
||||
Bind Variable 绑定变量
|
||||
Unbind Variable 解除变量绑定
|
||||
New Data Set 新建数据集
|
||||
Delete Data Set 删除数据集
|
||||
List Data Sets 列出数据集
|
||||
Select Data Set 选择数据集
|
||||
Next Data Set 下一个数据集
|
||||
Previous Data Set 上一个数据集
|
||||
Data Sets 数据集
|
||||
New Variable… 新建变量…
|
||||
New Data Set… 新建数据集…
|
||||
Edit Data Set… 编辑数据集…
|
||||
Data Set 数据集
|
||||
Kind 类型
|
||||
Variable 变量
|
||||
no document 没有文档
|
||||
no data set to edit 没有可编辑的数据集
|
||||
Make Text Dynamic 使文本动态化
|
||||
Make Visibility Dynamic 使可见性动态化
|
||||
Capture Data Set 捕获数据集
|
||||
Update Data Set 更新数据集
|
||||
Rename Data Set… 重命名数据集…
|
||||
Variable Options… 变量选项…
|
||||
Select Bound Object 选择绑定的对象
|
||||
Select All Bound Objects 选择所有绑定的对象
|
||||
Multiple objects 多个对象
|
||||
New Name 新名称
|
||||
no variable to rename 没有可重命名的变量
|
||||
no data set to rename 没有可重命名的数据集
|
||||
Variation: 变化:
|
||||
Vector Effects 矢量效果
|
||||
Vector Halftone 矢量半色调
|
||||
@@ -3391,6 +3426,7 @@
|
||||
Hide Others 隐藏其他
|
||||
High Performance (discrete) 高性能 (独立显卡)
|
||||
Highlight Rows 突出显示行
|
||||
Highlight Variable 突出显示变量
|
||||
Lock Others 锁定其他
|
||||
Merge Selected 合并所选图层
|
||||
Move Rows 移动行
|
||||
@@ -3602,3 +3638,88 @@ axis Both 两者
|
||||
effect Soft 柔和
|
||||
effect Horizontal 水平
|
||||
effect Vertical 垂直
|
||||
Edge 边缘
|
||||
System Title Bar 系统标题栏
|
||||
Value Axis 数值轴
|
||||
On Left Side 左侧
|
||||
On Right Side 右侧
|
||||
On Both Sides 两侧
|
||||
Short 短
|
||||
Full Width 全宽
|
||||
Distort 扭曲
|
||||
Diffuse Glow… 扩散亮光…
|
||||
Glass… 玻璃…
|
||||
Ocean Ripple… 海洋波纹…
|
||||
Diffuse Glow 扩散亮光
|
||||
Glass 玻璃
|
||||
Ocean Ripple 海洋波纹
|
||||
Graininess 颗粒
|
||||
Glow Amount 发光量
|
||||
Clear Amount 清除数量
|
||||
Ripple Size 波纹大小
|
||||
Ripple Magnitude 波纹幅度
|
||||
Blocks 块状
|
||||
Frosted 磨砂
|
||||
Tiny Lens 小镜头
|
||||
Rename Data Set 重命名数据集
|
||||
Rename Variable 重命名变量
|
||||
Variable Options 变量选项
|
||||
Edit Data Set 编辑数据集
|
||||
Define a variable first 请先定义一个变量
|
||||
Text 文本
|
||||
Visibility 可见性
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting 照明
|
||||
Revolve angle 旋转成形角度
|
||||
Axis side 轴的位置
|
||||
Rotation X X 轴旋转
|
||||
Rotation Y Y 轴旋转
|
||||
Rotation Z Z 轴旋转
|
||||
Shading 明暗处理
|
||||
Direction 方向
|
||||
Elevation 仰角
|
||||
Ambient 环境光
|
||||
Segments 分段数
|
||||
Show axis 显示轴
|
||||
Reset all 全部重置
|
||||
Reset to default 重置为默认值
|
||||
Reset all Revolve settings to their defaults. 将所有旋转成形设置重置为默认值。
|
||||
Drag values or labels. Double-click a value to type. 拖动数值或标签。双击数值即可输入。
|
||||
Drag rings to rotate. Shift snaps to 15°. 拖动圆环旋转。按住 Shift 以 15° 为步长调整。
|
||||
Drag the light, or its rings. Shift snaps to 15°. 拖动光源或圆环。按住 Shift 以 15° 为步长调整。
|
||||
lighting Light 光源
|
||||
Keep visible surfaces only when expanding 扩展时仅保留可见表面
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. 扩展外观时裁剪被遮挡的几何区域。实时预览保留完整表面。透明绘制或超出可见性处理限制的表面保留所有面。
|
||||
Accented Edges… 强化的边缘…
|
||||
Angled Strokes… 成角的线条…
|
||||
Crosshatch… 阴影线…
|
||||
Dark Strokes… 深色线条…
|
||||
Ink Outlines… 墨水轮廓…
|
||||
Spatter… 喷溅…
|
||||
Sprayed Strokes… 喷色描边…
|
||||
Sumi-e… 水墨画…
|
||||
Accented Edges 强化的边缘
|
||||
Angled Strokes 成角的线条
|
||||
Crosshatch 阴影线
|
||||
Dark Strokes 深色线条
|
||||
Ink Outlines 墨水轮廓
|
||||
Spatter 喷溅
|
||||
Sprayed Strokes 喷色描边
|
||||
Sumi-e 水墨画
|
||||
Direction Balance 方向平衡
|
||||
Stroke Length 描边长度
|
||||
Sharpness 锐化程度
|
||||
Strength 力度
|
||||
Balance 平衡
|
||||
Black Intensity 黑色强度
|
||||
White Intensity 白色强度
|
||||
Dark Intensity 深色强度
|
||||
Spray Radius 喷色半径
|
||||
Stroke Direction 描边方向
|
||||
Stroke Width 描边宽度
|
||||
Stroke Pressure 描边压力
|
||||
Right Diagonal 右对角线
|
||||
Left Diagonal 左对角线
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1170,6 +1170,7 @@
|
||||
Highlight Alternate Glyphs 醒目提示替代字符
|
||||
Highlight anchors on mouse over 滑鼠移過時醒目提示錨點
|
||||
Highlight Rows 反白顯示列
|
||||
Highlight Variable 突出顯示變數
|
||||
Highlight Substituted Fonts 醒目提示被取代的字體
|
||||
Highlight Substituted Glyphs 醒目提示被取代的字符
|
||||
History 步驟記錄
|
||||
@@ -2984,6 +2985,9 @@
|
||||
Untitled 未命名
|
||||
Update Link 更新連結
|
||||
Update Links 更新連結
|
||||
Update Changed Links 更新已變更的連結
|
||||
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 個連結影像已在磁碟上變更。要顯示新版本嗎?
|
||||
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 個在磁碟上變更的連結檔案
|
||||
Update Modified Links 更新已修改的連結
|
||||
Updated {} image(s) 已更新 {} 張影像
|
||||
UPPERCASE 全部大寫
|
||||
@@ -3035,6 +3039,38 @@
|
||||
V: V:
|
||||
Variable Width Profile 可變寬度描述檔
|
||||
Variables 變數
|
||||
New Variable 新增變數
|
||||
Delete Variable 刪除變數
|
||||
List Variables 列出變數
|
||||
Bind Variable 綁定變數
|
||||
Unbind Variable 解除變數綁定
|
||||
New Data Set 新增資料集
|
||||
Delete Data Set 刪除資料集
|
||||
List Data Sets 列出資料集
|
||||
Select Data Set 選取資料集
|
||||
Next Data Set 下一個資料集
|
||||
Previous Data Set 上一個資料集
|
||||
Data Sets 資料集
|
||||
New Variable… 新增變數…
|
||||
New Data Set… 新增資料集…
|
||||
Edit Data Set… 編輯資料集…
|
||||
Data Set 資料集
|
||||
Kind 類型
|
||||
Variable 變數
|
||||
no document 沒有文件
|
||||
no data set to edit 沒有可編輯的資料集
|
||||
Make Text Dynamic 使文字動態化
|
||||
Make Visibility Dynamic 使可見性動態化
|
||||
Capture Data Set 擷取資料集
|
||||
Update Data Set 更新資料集
|
||||
Rename Data Set… 重新命名資料集…
|
||||
Variable Options… 變數選項…
|
||||
Select Bound Object 選取繫結的物件
|
||||
Select All Bound Objects 選取所有繫結的物件
|
||||
Multiple objects 多個物件
|
||||
New Name 新名稱
|
||||
no variable to rename 沒有可重新命名的變數
|
||||
no data set to rename 沒有可重新命名的資料集
|
||||
Variation: 變化:
|
||||
Vector Effects 向量效果
|
||||
Vector Halftone 向量半色調
|
||||
@@ -3383,3 +3419,88 @@ axis Both 兩者
|
||||
Top Right 右上
|
||||
Bottom Right 右下
|
||||
effect Soft 柔和
|
||||
Edge 邊緣
|
||||
System Title Bar 系統標題列
|
||||
Value Axis 數值軸
|
||||
On Left Side 左側
|
||||
On Right Side 右側
|
||||
On Both Sides 兩側
|
||||
Short 短
|
||||
Full Width 全寬
|
||||
Distort 扭曲
|
||||
Diffuse Glow… 擴散光暈…
|
||||
Glass… 玻璃…
|
||||
Ocean Ripple… 海洋波紋…
|
||||
Diffuse Glow 擴散光暈
|
||||
Glass 玻璃
|
||||
Ocean Ripple 海洋波紋
|
||||
Graininess 顆粒
|
||||
Glow Amount 光暈量
|
||||
Clear Amount 清除量
|
||||
Ripple Size 波紋大小
|
||||
Ripple Magnitude 波紋強度
|
||||
Blocks 塊狀
|
||||
Frosted 磨砂
|
||||
Tiny Lens 小鏡頭
|
||||
Rename Data Set 重新命名資料集
|
||||
Rename Variable 重新命名變數
|
||||
Variable Options 變數選項
|
||||
Edit Data Set 編輯資料集
|
||||
Define a variable first 請先定義一個變數
|
||||
Text 文字
|
||||
Visibility 可見性
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting 照明
|
||||
Revolve angle 旋轉成形角度
|
||||
Axis side 軸的位置
|
||||
Rotation X X 軸旋轉
|
||||
Rotation Y Y 軸旋轉
|
||||
Rotation Z Z 軸旋轉
|
||||
Shading 明暗處理
|
||||
Direction 方向
|
||||
Elevation 仰角
|
||||
Ambient 環境光
|
||||
Segments 分段數
|
||||
Show axis 顯示軸
|
||||
Reset all 全部重設
|
||||
Reset to default 重設為預設值
|
||||
Reset all Revolve settings to their defaults. 將所有旋轉成形設定重設為預設值。
|
||||
Drag values or labels. Double-click a value to type. 拖曳數值或標籤。按兩下數值即可輸入。
|
||||
Drag rings to rotate. Shift snaps to 15°. 拖曳圓環旋轉。按住 Shift 以 15° 為間距調整。
|
||||
Drag the light, or its rings. Shift snaps to 15°. 拖曳光源或圓環。按住 Shift 以 15° 為間距調整。
|
||||
lighting Light 光源
|
||||
Keep visible surfaces only when expanding 展開時僅保留可見表面
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. 展開外觀時裁剪被遮擋的幾何區域。即時預覽保留完整表面。透明繪製或超出可見性處理限制的表面保留所有面。
|
||||
Accented Edges… 強調邊緣…
|
||||
Angled Strokes… 傾斜筆觸…
|
||||
Crosshatch… 交叉影線…
|
||||
Dark Strokes… 深色筆觸…
|
||||
Ink Outlines… 墨水輪廓…
|
||||
Spatter… 噴濺…
|
||||
Sprayed Strokes… 噴灑筆觸…
|
||||
Sumi-e… 水墨畫…
|
||||
Accented Edges 強調邊緣
|
||||
Angled Strokes 傾斜筆觸
|
||||
Crosshatch 交叉影線
|
||||
Dark Strokes 深色筆觸
|
||||
Ink Outlines 墨水輪廓
|
||||
Spatter 噴濺
|
||||
Sprayed Strokes 噴灑筆觸
|
||||
Sumi-e 水墨畫
|
||||
Direction Balance 方向平衡
|
||||
Stroke Length 筆觸長度
|
||||
Sharpness 銳利度
|
||||
Strength 力度
|
||||
Balance 平衡
|
||||
Black Intensity 黑色強度
|
||||
White Intensity 白色強度
|
||||
Dark Intensity 暗部強度
|
||||
Spray Radius 噴灑半徑
|
||||
Stroke Direction 筆觸方向
|
||||
Stroke Width 筆觸寬度
|
||||
Stroke Pressure 筆觸壓力
|
||||
Right Diagonal 右對角線
|
||||
Left Diagonal 左對角線
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
+62
-18
@@ -314,8 +314,9 @@ pub struct VectorcraftApp {
|
||||
pub services: Services,
|
||||
pub canvas: CanvasCache,
|
||||
pub perf: Perf,
|
||||
/// macOS: draw our own title strip under the traffic lights.
|
||||
pub integrated_titlebar: bool,
|
||||
/// macOS: the room the app bar keeps at its left for the window buttons over it, in points
|
||||
/// (0 where the OS draws no buttons over the app bar).
|
||||
pub titlebar_inset: f32,
|
||||
/// Last applied effect (Effect → Apply Last Effect).
|
||||
pub last_effect: Option<(String, serde_json::Value)>,
|
||||
/// Commands run through [`Self::run`] so far: the native menu bar reads its rows again when it
|
||||
@@ -352,6 +353,9 @@ pub struct VectorcraftApp {
|
||||
/// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
|
||||
/// double-click to maximize, caption buttons) and invisible edge zones resize the window.
|
||||
pub custom_titlebar: bool,
|
||||
/// Last window title sent to the OS (`ViewportCommand::Title`, see `chrome::sync_window_title`):
|
||||
/// sent again only when it changes, so idle frames don't spam the backend.
|
||||
pub(crate) last_window_title: String,
|
||||
/// The graphics adapter the window renders with ("name (backend)"), as the host reports it:
|
||||
/// shown in Help › About and `ui.inspect` for GPU bug reports. `None` when unknown.
|
||||
pub graphics_adapter: Option<String>,
|
||||
@@ -371,12 +375,6 @@ pub struct VectorcraftApp {
|
||||
/// The look for fonts installed or removed while the app was in the background, running
|
||||
/// ([`Self::refresh_installed_fonts`]): whether they were.
|
||||
font_check: Option<std::sync::mpsc::Receiver<bool>>,
|
||||
/// The window came back to the front: check the active document's linked files next frame
|
||||
/// ([`dialogs::missing_links::after_focus`]).
|
||||
pub(crate) links_check: bool,
|
||||
/// The changed linked files already offered for update (path, size and modification time), so
|
||||
/// coming back to the window doesn't ask about the same change twice.
|
||||
pub(crate) links_asked: std::collections::HashSet<dialogs::missing_links::FileStamp>,
|
||||
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
|
||||
pub(crate) paste_chord: shortcuts::PasteChord,
|
||||
/// Saves and exports running in the background (Preferences → File Handling).
|
||||
@@ -446,7 +444,7 @@ impl VectorcraftApp {
|
||||
cursors: Default::default(),
|
||||
},
|
||||
perf: Perf::default(),
|
||||
integrated_titlebar: false,
|
||||
titlebar_inset: 0.0,
|
||||
last_effect: None,
|
||||
run_count: 0,
|
||||
clipboard_out: None,
|
||||
@@ -467,6 +465,7 @@ impl VectorcraftApp {
|
||||
canvas_rect: None,
|
||||
hover_doc: None,
|
||||
custom_titlebar: false,
|
||||
last_window_title: String::new(),
|
||||
graphics_adapter: None,
|
||||
place: Default::default(),
|
||||
system_paste: false,
|
||||
@@ -474,8 +473,6 @@ impl VectorcraftApp {
|
||||
clipboard_probe: None,
|
||||
picks: picks::Picks::default(),
|
||||
font_check: None,
|
||||
links_check: false,
|
||||
links_asked: Default::default(),
|
||||
paste_chord: Default::default(),
|
||||
background: Default::default(),
|
||||
recovery: Default::default(),
|
||||
@@ -757,6 +754,54 @@ impl VectorcraftApp {
|
||||
std::mem::take(&mut self.ime_discard)
|
||||
}
|
||||
|
||||
/// Linked files another app changed while their document is open: every two seconds, and as
|
||||
/// soon as the window comes back to the front, stamp the active document's linked files on a
|
||||
/// worker thread and act on the changed ones as Preferences › File Handling › Update Links
|
||||
/// says ([`vectorcraft_engine::link_watch`]). No look starts while a dialog is open, so an
|
||||
/// Ask When Modified question never goes unseen.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn tick_link_updates(&mut self, ctx: &egui::Context, now: f64) {
|
||||
if let Some(r) = self.session.poll_link_scan() {
|
||||
match r {
|
||||
Ok(r) => {
|
||||
let n = |k: &str| r[k].as_array().map_or(0, Vec::len);
|
||||
if n("updated") > 0 {
|
||||
self.status(format!("Updated {} linked file(s) changed on disk", n("updated")));
|
||||
}
|
||||
if n("ask") > 0 {
|
||||
dialogs::missing_links::ask_update_changed(self, r["ask"].as_array().cloned().unwrap_or_default());
|
||||
}
|
||||
if n("modified") > 0 {
|
||||
self.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", n("modified")));
|
||||
}
|
||||
}
|
||||
Err(e) => self.status(e.to_string()),
|
||||
}
|
||||
}
|
||||
let (last_key, focus_key) = (egui::Id::new("linkWatch.lastScan"), egui::Id::new("linkWatch.focused"));
|
||||
let focused = ctx.input(|i| i.focused);
|
||||
let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused);
|
||||
ctx.data_mut(|d| d.insert_temp(focus_key, focused));
|
||||
if self.ui.dialog.is_some() || self.session.active().is_none() {
|
||||
return;
|
||||
}
|
||||
let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY);
|
||||
if now - last >= 2.0 || (focused && !was_focused) {
|
||||
// Walking the document's links costs a pass over it: only when a look is due.
|
||||
// `start_link_scan` starts none for a document without links, which then doesn't
|
||||
// wake the app for looks of its own (any frame still looks once one is due).
|
||||
ctx.data_mut(|d| d.insert_temp(last_key, now));
|
||||
self.session.start_link_scan();
|
||||
let links = self.session.link_scan.is_some();
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("linkWatch.links"), links));
|
||||
}
|
||||
if self.session.link_scan.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
} else if ctx.data(|d| d.get_temp::<bool>(egui::Id::new("linkWatch.links"))).unwrap_or(false) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(2000));
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a transient status message.
|
||||
pub fn status(&mut self, s: impl Into<String>) {
|
||||
self.ui.status = s.into();
|
||||
@@ -889,11 +934,10 @@ impl VectorcraftApp {
|
||||
self.system_paste = wanted && self.system_clipboard_pasteable();
|
||||
}
|
||||
self.poll_font_check(ctx);
|
||||
if std::mem::take(&mut self.links_check) {
|
||||
dialogs::missing_links::after_focus(self);
|
||||
}
|
||||
picks::poll(self, ctx);
|
||||
background::poll(self);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.tick_link_updates(ctx, now);
|
||||
if !self.background.jobs.is_empty() {
|
||||
// Keep the status bar's progress moving and pick the result up when it arrives.
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
@@ -918,6 +962,9 @@ impl VectorcraftApp {
|
||||
}
|
||||
shortcut_editor::sync(&self.ui);
|
||||
prefs_dialog::apply_runtime(self, ctx);
|
||||
// The OS title bar (and the taskbar / Alt-Tab entry) follows the active file; with the
|
||||
// system title bar this is where the document name lives, as the in-app mark is hidden.
|
||||
crate::chrome::sync_window_title(self, ctx);
|
||||
self.drain_control(ctx);
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
@@ -1025,10 +1072,7 @@ impl VectorcraftApp {
|
||||
match e {
|
||||
egui::Event::ModifiersChanged(m) => self.host_modifiers = *m,
|
||||
egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE,
|
||||
egui::Event::WindowFocused(true) => {
|
||||
self.refresh_installed_fonts();
|
||||
self.links_check = true;
|
||||
}
|
||||
egui::Event::WindowFocused(true) => self.refresh_installed_fonts(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
+78
-36
@@ -13,7 +13,7 @@ use crate::VectorcraftApp;
|
||||
use crate::io;
|
||||
use crate::panels::character::Face;
|
||||
use crate::state::{DockTab, next_zoom};
|
||||
use crate::theme::{self, Brightness, Tokens};
|
||||
use crate::theme::{Brightness, Tokens};
|
||||
use crate::widgets;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -47,6 +47,7 @@ fn sub(label: &'static str, items: Vec<Item>) -> Item {
|
||||
fn library_placeholders() -> Vec<Item> {
|
||||
vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")]
|
||||
}
|
||||
|
||||
use Item::Sep;
|
||||
|
||||
/// UI-level commands: (id, label, shortcut, params doc).
|
||||
@@ -1926,8 +1927,10 @@ fn selected_image(app: &VectorcraftApp, keep: impl Fn(&vectorcraft_doc::ImageObj
|
||||
})
|
||||
}
|
||||
|
||||
/// The in-window and native menu tree. `english_names` is Preferences › Type › Show Font Names in
|
||||
/// English (the Type → Font labels follow it; tests use the default `true`).
|
||||
/// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see
|
||||
/// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`).
|
||||
/// `english_names` is Preferences › Type › Show Font Names in English (the Type → Font labels
|
||||
/// follow it; tests use the default `true`).
|
||||
pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
menu_tree_named(true)
|
||||
}
|
||||
@@ -1936,24 +1939,13 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id }));
|
||||
vec![
|
||||
(
|
||||
// The application menu: About and Discord only. The in-window bar never shows it (File
|
||||
// is first there, as in Illustrator); on macOS `native_menu::mac_layout` composes the
|
||||
// App menu from this entry and the other menus (About from Help, Settings from Edit,
|
||||
// Quit from File, Language and Appearance synthesized). It stays first: `from_tree`
|
||||
// gives the first menu the App role.
|
||||
"VectorCraft",
|
||||
vec![
|
||||
c("About VectorCraft", "help.about"),
|
||||
c("Join Our Discord", "help.discord"),
|
||||
Sep,
|
||||
c("Settings…", "edit.preferences"),
|
||||
sub(
|
||||
"Language",
|
||||
std::iter::once(cp("Automatic", "app.language", json!({"lang": "auto"})))
|
||||
.chain(std::iter::once(Sep))
|
||||
.chain(crate::i18n::Lang::all().map(|l| cp(l.name(), "app.language", json!({"lang": l.code()}))))
|
||||
.collect(),
|
||||
),
|
||||
Sep,
|
||||
sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()),
|
||||
Sep,
|
||||
c("Quit VectorCraft", "app.quit"),
|
||||
],
|
||||
vec![c("About VectorCraft", "help.about"), c("Join Our Discord", "help.discord")],
|
||||
),
|
||||
(
|
||||
"File",
|
||||
@@ -2007,6 +1999,8 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
c("File Info…", "file.info"),
|
||||
Sep,
|
||||
c("Print…", "file.print"),
|
||||
Sep,
|
||||
c("Exit", "app.quit"),
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -2555,7 +2549,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
panel("Tabs", "tabs"),
|
||||
],
|
||||
),
|
||||
todo("Variables"),
|
||||
panel("Variables", crate::panels::variables::ID),
|
||||
Sep,
|
||||
sub("Brush Libraries", library_placeholders()),
|
||||
sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()),
|
||||
@@ -2754,15 +2748,14 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
}
|
||||
let mut clicked: Option<(String, Value)> = None;
|
||||
let tree = menu_tree_named(app.session.prefs.font_names_in_english);
|
||||
// No application menu in the window (as in Illustrator): the bar starts at File on every
|
||||
// platform. The VectorCraft menu stays in the tree: the native Mac bar takes it from there.
|
||||
let tree: Vec<(&str, Vec<Item>)> = tree.into_iter().filter(|(title, _)| *title != "VectorCraft").collect();
|
||||
let (end, open) = egui::MenuBar::new()
|
||||
.ui(ui, |ui| {
|
||||
let mut titles = Vec::with_capacity(tree.len());
|
||||
for (i, (title, items)) in tree.iter().enumerate() {
|
||||
let text = if i == 0 {
|
||||
egui::RichText::new(tl!(title)).font(theme::semibold(13.0)).color(t.text)
|
||||
} else {
|
||||
egui::RichText::new(tl!(title)).size(13.0).color(t.text)
|
||||
};
|
||||
for (title, items) in tree.iter() {
|
||||
let text = egui::RichText::new(tl!(title)).size(13.0).color(t.text);
|
||||
titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response);
|
||||
}
|
||||
(ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles))
|
||||
@@ -3000,6 +2993,11 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Variables (data merge): a name and a kind, a name and a row of values.
|
||||
if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) {
|
||||
crate::dialogs::variables::open(app, id, None);
|
||||
return;
|
||||
}
|
||||
// Repeat Options: a dialog with the selected repeat's current values.
|
||||
if id == "object.repeat.options"
|
||||
&& p.as_object().is_none_or(|o| o.is_empty())
|
||||
@@ -3305,8 +3303,10 @@ fn font_items(english_names: bool) -> Vec<Item> {
|
||||
|
||||
/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the
|
||||
/// vector effects): (submenu, the catalogue's menu path of its effects).
|
||||
const RASTER_MENUS: [(&str, &[&str]); 5] = [
|
||||
const RASTER_MENUS: [(&str, &[&str]); 7] = [
|
||||
("Blur", &["Effect", "Blur"]),
|
||||
("Brush Strokes", &["Effect", "Brush Strokes"]),
|
||||
("Distort", &["Effect", "Distort"]),
|
||||
("Pixelate", &["Effect", "Pixelate"]),
|
||||
("Sharpen", &["Effect", "Sharpen"]),
|
||||
("Texture", &["Effect", "Texture"]),
|
||||
@@ -3363,6 +3363,10 @@ fn effect_menu() -> Vec<Item> {
|
||||
};
|
||||
out.push(sub(sub_name, placeholder));
|
||||
} else {
|
||||
let mut items = items;
|
||||
if sub_name == "3D and Materials" {
|
||||
items.extend([todo("Extrude & Bevel…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")]);
|
||||
}
|
||||
out.push(sub(sub_name, items));
|
||||
}
|
||||
}
|
||||
@@ -3524,6 +3528,7 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme;
|
||||
|
||||
/// One headless frame of the in-window menu bar; returns its titles (left to right) as
|
||||
/// (rect, id of the title's popup).
|
||||
@@ -3553,6 +3558,43 @@ mod tests {
|
||||
(0..titles.len()).filter(|&i| egui::Popup::is_id_open(ctx, titles[i].1)).collect()
|
||||
}
|
||||
|
||||
/// File ends with Exit, running the app's own quit flow (unsaved documents are asked about).
|
||||
#[test]
|
||||
fn file_ends_with_exit() {
|
||||
let tree = menu_tree();
|
||||
let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap();
|
||||
let last = file.iter().rev().find(|i| !matches!(i, Item::Sep)).unwrap();
|
||||
assert!(matches!(last, Item::Cmd("Exit", "app.quit", _)), "{last:?}");
|
||||
}
|
||||
|
||||
/// No application menu in the window (as in Illustrator): the bar starts at File.
|
||||
#[test]
|
||||
fn the_bar_starts_at_file() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
|
||||
menu_bar(&mut app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
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 shown = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
let titles = ["File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"];
|
||||
for t in titles {
|
||||
assert!(shown.iter().any(|s| s == t), "{t} missing from {shown:?}");
|
||||
}
|
||||
assert!(!shown.iter().any(|s| s == "VectorCraft"), "{shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hovering_another_title_switches_the_open_menu() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
@@ -3561,9 +3603,9 @@ mod tests {
|
||||
theme::apply(&ctx, Default::default());
|
||||
bar_frame(&mut app, &ctx, vec![]);
|
||||
let titles = bar_frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(titles.len(), menu_tree().len());
|
||||
// 0 is the app menu, then File, Edit, Object.
|
||||
let (file, edit, object) = (titles[1].0.center(), titles[2].0.center(), titles[3].0.center());
|
||||
// No application menu in the window: the bar starts at File on every platform.
|
||||
assert_eq!(titles.len(), menu_tree().len() - 1);
|
||||
let (file, edit, object) = (titles[0].0.center(), titles[1].0.center(), titles[2].0.center());
|
||||
let frames = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut t = bar_frame(app, &ctx, events);
|
||||
for _ in 0..2 {
|
||||
@@ -3581,17 +3623,17 @@ mod tests {
|
||||
frames(&mut app, vec![egui::Event::PointerMoved(file)]);
|
||||
frames(&mut app, vec![button(file, true)]);
|
||||
let t = frames(&mut app, vec![button(file, false)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![1], "a click opens File");
|
||||
assert_eq!(open_titles(&ctx, &t), vec![0], "a click opens File");
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Edit opens it and closes File");
|
||||
assert_eq!(open_titles(&ctx, &t), vec![1], "hovering Edit opens it and closes File");
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(object)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![3], "hovering Object opens it and closes Edit");
|
||||
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Object opens it and closes Edit");
|
||||
|
||||
// A pointer resting on a title doesn't switch: File opened another way (the keyboard)
|
||||
// stays open while the pointer stays still over Object.
|
||||
egui::Popup::open_id(&ctx, t[1].1);
|
||||
egui::Popup::open_id(&ctx, t[0].1);
|
||||
let t = frames(&mut app, vec![]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![1], "a still pointer leaves the open menu alone");
|
||||
assert_eq!(open_titles(&ctx, &t), vec![0], "a still pointer leaves the open menu alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
//! - an **app menu** named VectorCraft: About; Settings, a submenu with one item per Preferences
|
||||
//! page (General… ⌘K first); Language; Appearance (the UI brightness); Services; Hide VectorCraft
|
||||
//! ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All; and Quit ⌘Q, still the app's own
|
||||
//! `app.quit`, so documents with unsaved changes are asked about. These leave the other menus
|
||||
//! (Edit › Preferences…, Help › About), and Join Our Discord stays under Help only;
|
||||
//! `app.quit`, so documents with unsaved changes are asked about. Language and Appearance have
|
||||
//! no in-window menu (as in PhotoCraft) and are synthesized here; Quit is File › Exit in the
|
||||
//! window and reads as Quit in the App menu. These leave the other menus (Edit › Preferences…,
|
||||
//! Help › About), and Join Our Discord stays under Help only;
|
||||
//! - **Window** gets Minimize ⌃⌘M, Zoom and Bring All to Front, and **Help** the system's search
|
||||
//! field;
|
||||
//! - a system key already bound to a command keeps the command's: the system item goes without.
|
||||
@@ -34,6 +36,7 @@ use serde_json::{Value, json};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::i18n::{Lang, tr, tr_ctx};
|
||||
use crate::menus::{self, Entry};
|
||||
use crate::theme::Brightness;
|
||||
|
||||
pub const APP_NAME: &str = "VectorCraft";
|
||||
/// Hide VectorCraft: an item of ours (AppKit's always takes ⌘H, which is View › Hide Edges).
|
||||
@@ -292,7 +295,9 @@ pub struct Layout {
|
||||
}
|
||||
|
||||
/// The Mac menu bar of `bar` (the in-window menus, [`from_tree`]): see the module docs.
|
||||
pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
|
||||
/// Language and Appearance have no in-window menu (as in PhotoCraft); when the tree doesn't
|
||||
/// carry them they are synthesized below, so the App menu keeps them.
|
||||
pub fn mac_layout(app: &VectorcraftApp, bar: &MenuBar, lang: Lang) -> Layout {
|
||||
let mut bar = bar.clone();
|
||||
let mut clashes = Vec::new();
|
||||
let bound = crate::shortcuts::all_shortcuts();
|
||||
@@ -316,8 +321,10 @@ pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
|
||||
let about = take_items(&mut bar, "help.about").into_iter().next();
|
||||
let settings = take_items(&mut bar, "edit.preferences").into_iter().next();
|
||||
let quit = take_items(&mut bar, "app.quit").into_iter().next();
|
||||
let language = take_submenu(&mut bar, "app.language");
|
||||
let appearance = take_submenu(&mut bar, "window.brightness");
|
||||
// Language and UI Brightness live only in the App menu: taken from the tree when it carries
|
||||
// them, else synthesized here (the in-window bar has neither).
|
||||
let language = take_submenu(&mut bar, "app.language").or_else(|| Some(language_node(app, lang)));
|
||||
let appearance = take_submenu(&mut bar, "window.brightness").or_else(|| Some(appearance_node(app, lang)));
|
||||
// Its other items (Join Our Discord) stay where the in-window bar also has them: Help.
|
||||
bar.menus.retain(|m| m.role != MenuRole::App);
|
||||
|
||||
@@ -333,7 +340,11 @@ pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
|
||||
}));
|
||||
app.extend([Node::Separator, Node::Standard(Standard::Services), Node::Separator]);
|
||||
app.extend([Node::Item(hide), Node::Item(hide_others), Node::Standard(Standard::ShowAll), Node::Separator]);
|
||||
app.extend(quit.map(Node::Item));
|
||||
// Quit is File › Exit in the window; in the App menu it reads as a Mac Quit item.
|
||||
app.extend(quit.map(|mut q| {
|
||||
q.label = tr(lang, "Quit VectorCraft").into();
|
||||
Node::Item(q)
|
||||
}));
|
||||
bar.menus.insert(0, Menu { title: APP_NAME.into(), role: MenuRole::App, children: app });
|
||||
|
||||
if let Some(window) = bar.menus.iter_mut().find(|m| m.role == MenuRole::Window) {
|
||||
@@ -350,6 +361,40 @@ pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
|
||||
Layout { bar, clashes }
|
||||
}
|
||||
|
||||
/// Language in the app menu, after Settings: Automatic, then every UI language in its own name,
|
||||
/// with the `interfaceLanguage` preference checked.
|
||||
fn language_node(app: &VectorcraftApp, lang: Lang) -> Node {
|
||||
let mut children = vec![Node::Item(native_item(app, "Automatic", "app.language", json!({"lang": "auto"}))), Node::Separator];
|
||||
children.extend(Lang::all().map(|l| Node::Item(native_item(app, l.name(), "app.language", json!({"lang": l.code()})))));
|
||||
Node::Submenu { label: tr(lang, "Language").into(), children }
|
||||
}
|
||||
|
||||
/// Appearance in the app menu (the UI-brightness theme, as Mac apps call it).
|
||||
fn appearance_node(app: &VectorcraftApp, lang: Lang) -> Node {
|
||||
let children =
|
||||
Brightness::ALL.iter().map(|b| Node::Item(native_item(app, b.label(), "window.brightness", json!({"brightness": b.id()})))).collect();
|
||||
Node::Submenu { label: tr_ctx(lang, "theme", "Appearance").into(), children }
|
||||
}
|
||||
|
||||
/// A native item running `command`, built like [`from_tree`] builds it (translated label,
|
||||
/// shortcut, enabled and checked as they read now).
|
||||
fn native_item(app: &VectorcraftApp, label: &'static str, command: &'static str, params: Value) -> Item {
|
||||
let item = menus::Item::Cmd(label, command, params);
|
||||
match menus::entry(app, &item) {
|
||||
Some(Entry::Item(row)) => Item {
|
||||
command: row.command.map(|(id, _)| id),
|
||||
params: row.command.map_or(Value::Null, |(_, p)| p.clone()),
|
||||
source: row.source,
|
||||
label: row.label.into_owned(),
|
||||
shortcut: row.shortcut,
|
||||
enabled: row.enabled,
|
||||
checked: row.checked,
|
||||
},
|
||||
// The commands always show; the fallback keeps the structure.
|
||||
_ => Item { command: Some(command), params: Value::Null, source: label, label: label.into(), shortcut: None, enabled: true, checked: None },
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings ▸: one item per page of the Preferences dialog (its own list), each opening it there;
|
||||
/// the first (General…) has Preferences' shortcut (⌘K), as in Illustrator.
|
||||
fn settings_pages(preferences: &Item, lang: Lang) -> Vec<Node> {
|
||||
@@ -413,7 +458,7 @@ fn take_submenu(bar: &mut MenuBar, command: &str) -> Option<Node> {
|
||||
|
||||
/// VectorCraft's menus as a Mac menu bar, as they show now.
|
||||
pub fn layout(app: &VectorcraftApp) -> Layout {
|
||||
mac_layout(&from_tree(app, &menus::menu_tree_named(app.session.prefs.font_names_in_english)), crate::i18n::current())
|
||||
mac_layout(app, &from_tree(app, &menus::menu_tree_named(app.session.prefs.font_names_in_english)), crate::i18n::current())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------- the app's side
|
||||
|
||||
@@ -42,6 +42,7 @@ pub mod tabs;
|
||||
pub mod text_styles;
|
||||
pub mod transform;
|
||||
pub mod transparency;
|
||||
pub mod variables;
|
||||
|
||||
use egui::{Rect, Sense, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
@@ -136,6 +137,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
||||
"tabs" => tabs::show(app, ui),
|
||||
flattener_preview::ID => flattener_preview::show(app, ui),
|
||||
"attributes" => attributes::show(app, ui),
|
||||
variables::ID => variables::show(app, ui),
|
||||
"colorThemes" => color_themes::show(app, ui),
|
||||
links::ID => links::show(app, ui),
|
||||
asset_export::ID => asset_export::show(app, ui),
|
||||
@@ -187,6 +189,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool
|
||||
links::ID => links::menu(app, ui),
|
||||
asset_export::ID => asset_export::menu(app, ui),
|
||||
css_properties::ID => css_properties::menu(app, ui),
|
||||
variables::ID => variables::menu(app, ui),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
//! Variables panel: the document's variables (what they drive, which objects) and its
|
||||
//! datasets. Clicking a dataset applies it; clicking a variable selects what it binds; the
|
||||
//! arrows step to the next or previous one. Everything the reference panel does here runs a
|
||||
//! `variable.*` / `dataset.*` command, so the panel and an agent drive the document the same
|
||||
//! way — the commands are also in Window › Variables, the palette and over MCP.
|
||||
//!
|
||||
//! A row's name goes in italics when an edit has moved the art away from what the row says:
|
||||
//! the document then no longer matches the active dataset, and "Update Data Set" writes the
|
||||
//! art back into it.
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeId, NodeKind, VariableKind};
|
||||
use vectorcraft_engine::DocState;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::widgets;
|
||||
|
||||
/// The panel id, as the dock, the Window menu and `window.panel` name it.
|
||||
pub const ID: &str = "variables";
|
||||
|
||||
/// One variable row: its kind's icon, its name, and the object it drives.
|
||||
struct VarRow {
|
||||
name: String,
|
||||
kind: VariableKind,
|
||||
bound: Vec<u64>,
|
||||
/// The name of the object it drives, or "Multiple objects" when it drives more than one.
|
||||
object: String,
|
||||
}
|
||||
|
||||
/// One dataset row: its name and how many values it carries.
|
||||
struct SetRow {
|
||||
name: String,
|
||||
values: usize,
|
||||
active: bool,
|
||||
/// False once an edit has moved the art away from what this row says.
|
||||
matches: bool,
|
||||
}
|
||||
|
||||
/// The row the panel acts on: Delete, Options… and Select Bound Object all mean it. A name
|
||||
/// the document no longer defines highlights nothing (deleted from the palette, undone).
|
||||
fn highlighted_row(st: &DocState) -> Option<String> {
|
||||
st.variables_highlight.clone().filter(|h| st.doc.variables.variable(h).is_some())
|
||||
}
|
||||
|
||||
/// What a variable drives, as object ids: what Select Bound Object selects.
|
||||
fn bound_objects(st: &DocState, name: &str) -> Vec<u64> {
|
||||
st.doc.variables.objects_of(name).iter().map(|i| i.0).collect()
|
||||
}
|
||||
|
||||
/// A row click: highlight the row, and select what it drives (its bindings are left alone).
|
||||
pub(crate) fn click_row(app: &mut VectorcraftApp, name: &str, bound: &[u64]) {
|
||||
app.run("variable.highlight", json!({"variable": name})).ok();
|
||||
if !bound.is_empty() {
|
||||
app.run("select.set", json!({"ids": bound})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Select Bound Object: select what the highlighted variable drives; its bindings stay.
|
||||
pub(crate) fn select_bound_object(app: &mut VectorcraftApp) {
|
||||
let ids = app.session.active().map(|st| highlighted_row(st).map(|h| bound_objects(st, &h))).unwrap_or_default().unwrap_or_default();
|
||||
if !ids.is_empty() {
|
||||
app.run("select.set", json!({"ids": ids})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the selection allows the bottom bar's and the panel menu's commands to do.
|
||||
struct Picked {
|
||||
selected: Vec<u64>,
|
||||
has_selection: bool,
|
||||
/// Unbind needs a bound object under the selection, not just any selection.
|
||||
selection_bound: bool,
|
||||
/// Make Text Dynamic needs exactly one type object: a text variable only drives type.
|
||||
single_type: bool,
|
||||
}
|
||||
|
||||
fn picked(st: &vectorcraft_engine::DocState) -> Picked {
|
||||
let selected: Vec<u64> = st.selection.objects.iter().map(|i| i.0).collect();
|
||||
let selection_bound = selected.iter().any(|id| st.doc.variables.bindings.contains_key(&NodeId(*id)));
|
||||
let single_type = matches!(selected.as_slice(), [one] if st.doc.node(NodeId(*one)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))));
|
||||
Picked { has_selection: !selected.is_empty(), selected, selection_bound, single_type }
|
||||
}
|
||||
|
||||
/// Delete Variable: the highlighted variable only (the command's default); the rest stay.
|
||||
pub(crate) fn delete_highlighted(app: &mut VectorcraftApp) {
|
||||
app.run("variable.delete", json!({})).ok();
|
||||
}
|
||||
|
||||
/// Delete Data Set: the current one only (the command's default); the rest of the rows stay.
|
||||
pub(crate) fn delete_active_dataset(app: &mut VectorcraftApp) {
|
||||
app.run("dataset.delete", json!({})).ok();
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(st) = app.session.active() else {
|
||||
widgets::dim_label(ui, tl!("No document"));
|
||||
return;
|
||||
};
|
||||
// Owned snapshots: the rows below run commands, which borrow the app mutably.
|
||||
let vars: Vec<VarRow> = st
|
||||
.doc
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let bound: Vec<u64> = st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect();
|
||||
let object = match bound.as_slice() {
|
||||
[] => String::new(),
|
||||
[one] => st.doc.node(NodeId(*one)).map(|n| n.display_name()).unwrap_or_default(),
|
||||
_ => tl!("Multiple objects").to_string(),
|
||||
};
|
||||
VarRow { name: v.name.clone(), kind: v.kind, bound, object }
|
||||
})
|
||||
.collect();
|
||||
// What the art holds now, read once for every row's drift check.
|
||||
let current = st.doc.captured_values();
|
||||
let sets: Vec<SetRow> = st
|
||||
.doc
|
||||
.variables
|
||||
.datasets
|
||||
.iter()
|
||||
.map(|d| SetRow {
|
||||
name: d.name.clone(),
|
||||
values: d.values.len(),
|
||||
active: st.doc.variables.active_dataset.as_deref() == Some(d.name.as_str()),
|
||||
matches: d.matches(¤t),
|
||||
})
|
||||
.collect();
|
||||
let active = sets.iter().find(|s| s.active);
|
||||
let Picked { selected, has_selection, selection_bound, single_type } = picked(st);
|
||||
let highlighted = highlighted_row(st);
|
||||
|
||||
// The current data set across the top: its name, and the arrows that step through them.
|
||||
ui.horizontal(|ui| {
|
||||
widgets::field_label(ui, tl!("Data Set"));
|
||||
let names: Vec<&str> = sets.iter().map(|s| s.name.as_str()).collect();
|
||||
let current = active.map(|s| s.name.as_str()).unwrap_or("");
|
||||
let picked = widgets::dropdown_names(ui, "variables-active", current, &names, 160.0);
|
||||
if let Some(name) = picked.and_then(|i| names.get(i))
|
||||
&& *name != current
|
||||
{
|
||||
app.run("dataset.select", json!({"name": name})).ok();
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let has = !sets.is_empty();
|
||||
if widgets::icon_button_enabled(ui, "chevron-left", tl!("Previous Data Set"), false, has, 24.0).clicked() {
|
||||
app.run("dataset.prev", json!({})).ok();
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, "chevron-right", tl!("Next Data Set"), false, has, 24.0).clicked() {
|
||||
app.run("dataset.next", json!({})).ok();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
widgets::subheader(ui, tl!("Variables"));
|
||||
if vars.is_empty() {
|
||||
widgets::dim_label(ui, tl!("None"));
|
||||
}
|
||||
for row in &vars {
|
||||
ui.horizontal(|ui| {
|
||||
// The kind's own icon, so a text variable and a visibility one read apart.
|
||||
let icon = match row.kind {
|
||||
VariableKind::Text => "type",
|
||||
VariableKind::Visibility => "eye",
|
||||
};
|
||||
ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0)));
|
||||
// The object a variable drives is named beside it, as the reference panel does.
|
||||
let text = if row.object.is_empty() { row.name.clone() } else { format!("{} · {}", row.name, row.object) };
|
||||
let r = ui.selectable_label(highlighted.as_deref() == Some(row.name.as_str()), text);
|
||||
// A click highlights the row and selects what it drives; a double click opens
|
||||
// Variable Options for it.
|
||||
if r.clicked() {
|
||||
click_row(app, &row.name, &row.bound);
|
||||
}
|
||||
if r.double_clicked() {
|
||||
crate::dialogs::variables::open(app, "variable.rename", Some(&row.name));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
widgets::subheader(ui, tl!("Data Sets"));
|
||||
if sets.is_empty() {
|
||||
widgets::dim_label(ui, tl!("None"));
|
||||
}
|
||||
for row in &sets {
|
||||
// The active row is ticked and selected; a row the art has drifted from is in
|
||||
// italics, so an edit that left the document not matching it is visible.
|
||||
let label = format!("{}{} · {}", if row.active { "● " } else { "" }, row.name, row.values);
|
||||
let mut text = egui::RichText::new(label);
|
||||
if !row.matches {
|
||||
text = text.italics();
|
||||
}
|
||||
if ui.selectable_label(row.active, text).clicked() && !row.active {
|
||||
app.run("dataset.select", json!({"name": row.name})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// The bottom bar: make the selection dynamic, and define and delete what the lists show.
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, single_type, 24.0).clicked() {
|
||||
app.run("variable.makeTextDynamic", json!({})).ok();
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection, 24.0).clicked() {
|
||||
app.run("variable.makeVisibilityDynamic", json!({})).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, "plus", tl!("New Variable…"), false, 24.0).clicked() {
|
||||
crate::menus::invoke(app, "variable.define", json!({}));
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, selection_bound, 24.0).clicked() {
|
||||
app.run("variable.unbind", json!({"ids": selected})).ok();
|
||||
}
|
||||
// The bin deletes the highlighted variable only, as the reference panel's does.
|
||||
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, highlighted.is_some(), 24.0).clicked() {
|
||||
delete_highlighted(app);
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
// Capture records what the art holds now as a new row (paste: take it in); Update
|
||||
// writes the art back into the row that is active (save: write it out).
|
||||
if widgets::icon_button(ui, "clipboard-paste", tl!("Capture Data Set"), false, 24.0).clicked() {
|
||||
app.run("dataset.capture", json!({})).ok();
|
||||
}
|
||||
let can_update = active.is_some();
|
||||
if widgets::icon_button_enabled(ui, "save", tl!("Update Data Set"), false, can_update, 24.0).clicked() {
|
||||
app.run("dataset.update", json!({})).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The panel's (≡) menu: the same commands as its bars and lists, for the ones that act on one
|
||||
/// row rather than on the selection.
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let vars: Vec<String> = st.doc.variables.variables.iter().map(|v| v.name.clone()).collect();
|
||||
let sets: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
||||
let active = st.doc.variables.active_dataset.clone();
|
||||
let bound: Vec<u64> = st.doc.variables.bindings.keys().map(|i| i.0).collect();
|
||||
let highlighted = highlighted_row(st);
|
||||
let highlighted_bound: Vec<u64> = highlighted.as_ref().map(|h| bound_objects(st, h)).unwrap_or_default();
|
||||
let Picked { selected, has_selection, selection_bound, single_type } = picked(st);
|
||||
|
||||
if widgets::menu_item(ui, tl!("New Variable…"), true, false) {
|
||||
crate::menus::invoke(app, "variable.define", json!({}));
|
||||
}
|
||||
// Variable Options renames the highlighted variable, else the first.
|
||||
if widgets::menu_item(ui, tl!("Variable Options…"), !vars.is_empty(), false) {
|
||||
let name = highlighted.clone().or_else(|| vars.first().cloned()).unwrap_or_default();
|
||||
crate::dialogs::variables::open(app, "variable.rename", Some(&name));
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Make Text Dynamic"), single_type, false) {
|
||||
app.run("variable.makeTextDynamic", json!({})).ok();
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Make Visibility Dynamic"), has_selection, false) {
|
||||
app.run("variable.makeVisibilityDynamic", json!({})).ok();
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Unbind Variable"), selection_bound, false) {
|
||||
app.run("variable.unbind", json!({"ids": selected})).ok();
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Delete Variable"), highlighted.is_some(), false) {
|
||||
delete_highlighted(app);
|
||||
}
|
||||
ui.separator();
|
||||
if widgets::menu_item(ui, tl!("Capture Data Set"), true, false) {
|
||||
app.run("dataset.capture", json!({})).ok();
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Update Data Set"), active.is_some(), false) {
|
||||
app.run("dataset.update", json!({})).ok();
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("New Data Set…"), true, false) {
|
||||
crate::menus::invoke(app, "dataset.new", json!({}));
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Edit Data Set…"), active.is_some(), false) {
|
||||
crate::menus::invoke(app, "dataset.set", json!({}));
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Rename Data Set…"), !sets.is_empty(), false) {
|
||||
let name = active.clone().or_else(|| sets.first().cloned()).unwrap_or_default();
|
||||
crate::dialogs::variables::open(app, "dataset.rename", Some(&name));
|
||||
}
|
||||
// Only the current data set goes: the rest of the rows stay.
|
||||
if widgets::menu_item(ui, tl!("Delete Data Set"), active.is_some(), false) {
|
||||
delete_active_dataset(app);
|
||||
}
|
||||
ui.separator();
|
||||
// What the highlighted variable drives, selected — its bindings are left alone.
|
||||
if widgets::menu_item(ui, tl!("Select Bound Object"), !highlighted_bound.is_empty(), false) {
|
||||
select_bound_object(app);
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Select All Bound Objects"), !bound.is_empty(), false) {
|
||||
app.run("select.set", json!({"ids": bound})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn app_with_data() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
let t = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Alice"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
app.session.execute("variable.bind", &json!({"variable": "Name", "ids": [t]})).unwrap();
|
||||
app.session.execute("dataset.capture", &json!({})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draws_headless() {
|
||||
let mut app = app_with_data();
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
let text = crate::tests_labels::painted_text(&mut app, show);
|
||||
assert!(text.contains("Name") && text.contains("Data Sets") && text.contains("Data Set 1"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_the_art_left_behind_is_in_italics() {
|
||||
let mut app = app_with_data();
|
||||
let t = app.session.doc().unwrap().doc.variables.objects_of("Name")[0];
|
||||
// The document no longer matches the row once the art is edited.
|
||||
app.session.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap();
|
||||
let text = crate::tests_labels::painted_text(&mut app, show);
|
||||
assert!(text.contains("Data Set 1"), "{text}");
|
||||
// …and the row's drift is what `dataset.list` reports, so an agent sees it too.
|
||||
let sets = app.run("dataset.list", json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["matches"], json!(false));
|
||||
// Updating writes the art back into the row.
|
||||
app.run("dataset.update", json!({})).unwrap();
|
||||
assert_eq!(app.run("dataset.list", json!({})).unwrap()["datasets"][0]["matches"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_menu_lists_the_row_commands() {
|
||||
let mut app = app_with_data();
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| menu(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
let text = crate::tests_labels::painted_text(&mut app, menu);
|
||||
for item in [
|
||||
"New Variable…",
|
||||
"Variable Options…",
|
||||
"Capture Data Set",
|
||||
"Update Data Set",
|
||||
"Rename Data Set…",
|
||||
"Delete Data Set",
|
||||
"Select Bound Object",
|
||||
"Select All Bound Objects",
|
||||
] {
|
||||
assert!(text.contains(item), "{item} missing from the panel menu:\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the panel acts on is the highlight, and only while it names a variable: a stale one
|
||||
/// (deleted elsewhere, undone) highlights nothing, so Delete and Options… stay disabled.
|
||||
#[test]
|
||||
fn a_highlight_of_a_deleted_variable_highlights_nothing() {
|
||||
let mut app = app_with_data();
|
||||
app.session.execute("variable.highlight", &json!({"variable": "Name"})).unwrap();
|
||||
assert_eq!(highlighted_row(app.session.doc().unwrap()).as_deref(), Some("Name"));
|
||||
assert_eq!(bound_objects(app.session.doc().unwrap(), "Name").len(), 1);
|
||||
app.session.execute("variable.delete", &json!({"names": ["Name"]})).unwrap();
|
||||
assert!(highlighted_row(app.session.doc().unwrap()).is_none());
|
||||
}
|
||||
|
||||
/// The reviewer's regression case, through the calls the panel and its menu make: highlight
|
||||
/// a variable, Select Bound Object, then delete one variable and the current dataset. The
|
||||
/// selection lands on what the row drives with the binding intact, and the others remain.
|
||||
#[test]
|
||||
fn selecting_and_deleting_act_on_the_highlighted_row_only() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
let a = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Alice"})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = app.session.execute("text.create", &json!({"x": 10, "y": 80, "text": "Bob"})).unwrap()["id"].as_u64().unwrap();
|
||||
for (name, id) in [("Name", a), ("Other", b)] {
|
||||
app.session.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
|
||||
app.session.execute("variable.bind", &json!({"variable": name, "ids": [id]})).unwrap();
|
||||
}
|
||||
app.session.execute("dataset.capture", &json!({})).unwrap();
|
||||
app.session.execute("dataset.capture", &json!({})).unwrap();
|
||||
app.session.execute("dataset.select", &json!({"name": "Data Set 1"})).unwrap();
|
||||
|
||||
// A row click highlights; Select Bound Object selects what it drives, binding intact.
|
||||
click_row(&mut app, "Name", &[a]);
|
||||
assert_eq!(app.session.doc().unwrap().selection.objects, vec![NodeId(a)]);
|
||||
assert!(!app.session.doc().unwrap().doc.variables.objects_of("Name").is_empty());
|
||||
select_bound_object(&mut app);
|
||||
assert_eq!(app.session.doc().unwrap().selection.objects, vec![NodeId(a)]);
|
||||
assert!(!app.session.doc().unwrap().doc.variables.objects_of("Name").is_empty());
|
||||
|
||||
// Delete Variable deletes the highlighted one; Delete Data Set the current one.
|
||||
delete_highlighted(&mut app);
|
||||
delete_active_dataset(&mut app);
|
||||
let vars = app.run("variable.list", json!({})).unwrap();
|
||||
assert_eq!(vars["variables"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(vars["variables"][0]["name"], "Other");
|
||||
let sets = app.run("dataset.list", json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(sets["datasets"][0]["name"], "Data Set 2");
|
||||
|
||||
// The panel and its menu still draw with the highlight gone stale.
|
||||
let text = crate::tests_labels::painted_text(&mut app, show);
|
||||
assert!(text.contains("Other") && text.contains("Data Set 2"), "{text}");
|
||||
let text = crate::tests_labels::painted_text(&mut app, menu);
|
||||
assert!(text.contains("Select Bound Object"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_is_registered_with_its_own_icon() {
|
||||
let row = crate::state::ICON_PANELS.iter().find(|(id, ..)| *id == ID).expect("in the icon column");
|
||||
assert_eq!(row.1, "Variables");
|
||||
assert_ne!(row.2, "dc-list-view", "Document Info's icon, which the panel used to share");
|
||||
assert!(crate::icons::exists(row.2), "{} is not a bundled icon", row.2);
|
||||
}
|
||||
|
||||
/// The collapsed column draws `ICON_PANEL_GROUPS`, not `ICON_PANELS`: a panel registered
|
||||
/// but in no group opens from the Window menu and then has nowhere to collapse to. Both
|
||||
/// lists need an entry, and this is the one that was missing.
|
||||
#[test]
|
||||
fn the_panel_reaches_the_collapsed_icon_column() {
|
||||
let in_column = crate::state::ICON_PANEL_GROUPS.iter().any(|g| g.contains(&ID));
|
||||
assert!(in_column, "{ID} is in ICON_PANELS but in no ICON_PANEL_GROUPS row, so the collapsed column never draws it");
|
||||
// Every id in a group names a panel that is registered with a label and an icon.
|
||||
for group in crate::state::ICON_PANEL_GROUPS {
|
||||
for id in *group {
|
||||
let row = crate::state::ICON_PANELS.iter().find(|p| p.0 == *id).unwrap_or_else(|| panic!("{id} is in a group but not registered"));
|
||||
assert!(!row.1.is_empty() && crate::icons::exists(row.2), "{id}: {} / {}", row.1, row.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Window › Variables opens the panel, as Window's other panel items do; its commands are in
|
||||
/// the panel, its menu and the command palette.
|
||||
#[test]
|
||||
fn window_variables_opens_the_panel() {
|
||||
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
let entries = crate::menus::menu_entries(&app);
|
||||
let panel = entries
|
||||
.iter()
|
||||
.find(|e| e.command.as_deref() == Some("window.panel") && e.params.get("panel").and_then(serde_json::Value::as_str) == Some(ID))
|
||||
.expect("the Variables panel is listed");
|
||||
assert_eq!((panel.path.clone(), panel.label.as_str()), (vec!["Window".to_string()], "Variables"));
|
||||
assert!(!entries.iter().any(|e| e.path == ["Window", "Variables"]), "no Variables submenu");
|
||||
}
|
||||
|
||||
/// A menu item that needs a name opens the dialog rather than running the command with
|
||||
/// empty params, which would only say "missing `name`".
|
||||
#[test]
|
||||
fn the_menu_dialogs_open_instead_of_failing() {
|
||||
let mut app = app_with_data();
|
||||
for (cmd, field) in [
|
||||
("variable.define", "kind"),
|
||||
("variable.rename", "newName"),
|
||||
("variable.bind", "variable"),
|
||||
("dataset.new", "name"),
|
||||
("dataset.rename", "newName"),
|
||||
("dataset.select", "name"),
|
||||
] {
|
||||
app.ui.dialog = None;
|
||||
crate::menus::invoke(&mut app, cmd, json!({}));
|
||||
let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("{cmd} opened no dialog"));
|
||||
assert_eq!(d.kind, crate::dialogs::variables::KIND, "{cmd}");
|
||||
assert_eq!(d.str("__command"), cmd);
|
||||
assert!(d.fields.contains_key(field), "{cmd}: no `{field}` field");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,14 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
|
||||
PrefKind::Bool if sp.key == "animatedZoom" => {
|
||||
ui.add_enabled_ui(d.bool("gpuPerformance"), |ui| bool_row(ui, d, sp.key, sp.label));
|
||||
}
|
||||
// Windows and Linux only: macOS always has the system's title bar, the web has none.
|
||||
PrefKind::Bool if sp.key == "systemTitleBar" => {
|
||||
if cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) {
|
||||
bool_row(ui, d, sp.key, sp.label);
|
||||
// The window's decorations are chosen once, when the app starts.
|
||||
ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0));
|
||||
}
|
||||
}
|
||||
PrefKind::Bool => bool_row(ui, d, sp.key, sp.label),
|
||||
PrefKind::Num { min, max, unit } => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
@@ -530,6 +538,37 @@ mod tests {
|
||||
assert_eq!(saved["engine_prefs"]["gpuPreference"], json!("highPerformance"));
|
||||
}
|
||||
|
||||
/// User Interface › System Title Bar: shown with its next-launch note, applied by OK and
|
||||
/// saved with the UI state under the key the desktop app reads before the window opens.
|
||||
#[test]
|
||||
fn system_title_bar_preference_shows_and_persists() {
|
||||
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 a = app();
|
||||
open(&mut a, Some("User Interface"));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
// The window sizes itself on the first frame and draws on the second.
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let mut shown = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
assert!(shown.iter().any(|t| t == "System Title Bar"), "{shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}");
|
||||
a.ui.dialog.as_mut().unwrap().fields.insert("systemTitleBar".into(), json!(true));
|
||||
confirm(&mut a).unwrap();
|
||||
assert!(a.session.prefs.system_title_bar);
|
||||
let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap();
|
||||
assert_eq!(saved["engine_prefs"]["systemTitleBar"], json!(true));
|
||||
}
|
||||
|
||||
/// Units › Numbers Without Units Are Points (#394): the preference reaches the fields when it
|
||||
/// changes, and its checkbox is dimmed unless a unit is Picas.
|
||||
#[test]
|
||||
|
||||
@@ -213,11 +213,15 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
if app.ui.dialog.is_some() || app.ui.palette_open {
|
||||
if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) && crate::dialogs::revolve_gizmo::dragging(ctx) {
|
||||
// Escape first restores this rotation gesture. The settings stay open.
|
||||
return;
|
||||
}
|
||||
if crate::shortcut_editor::is_recording(app) {
|
||||
return;
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||||
app.ui.dialog = None;
|
||||
crate::dialogs::cancel(app);
|
||||
app.ui.palette_open = false;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -138,9 +138,14 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
||||
(crate::panels::links::ID, "Links", "link"),
|
||||
(crate::panels::asset_export::ID, "Asset Export", "share-2"),
|
||||
(crate::panels::css_properties::ID, "CSS Properties", "globe"),
|
||||
(crate::panels::variables::ID, "Variables", "chart-column"),
|
||||
];
|
||||
|
||||
/// Groups of icon panels separated by dividers in the collapsed column.
|
||||
///
|
||||
/// This, not [`ICON_PANELS`], is what the collapsed column draws: a panel listed there but in
|
||||
/// no group can be opened from the Window menu and `window.panel`, but never appears as an
|
||||
/// icon. A panel that belongs in the column needs a row here as well as its entry above.
|
||||
pub const ICON_PANEL_GROUPS: &[&[&str]] = &[
|
||||
&["color", "colorGuide"],
|
||||
&["swatches", "brushes", "symbols", "patternOptions"],
|
||||
@@ -150,6 +155,7 @@ pub const ICON_PANEL_GROUPS: &[&[&str]] = &[
|
||||
&["transform", "align", "pathfinder"],
|
||||
&["character", "paragraph", "glyphs"],
|
||||
&["history", "actions", "info", "navigator"],
|
||||
&["variables"],
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -78,6 +78,41 @@ fn effect_menu_lists_the_video_filters_last() {
|
||||
assert!(at("Sharpen") < at("Video"));
|
||||
}
|
||||
|
||||
/// Effect › Distort, between Blur and Pixelate: each filter opens its dialog.
|
||||
#[test]
|
||||
fn effect_menu_lists_the_distort_filters_between_blur_and_pixelate() {
|
||||
let app = app();
|
||||
let entries = menus::menu_entries(&app);
|
||||
let distort: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Distort"]).collect();
|
||||
let labels: Vec<&str> = distort.iter().map(|e| e.label.as_str()).collect();
|
||||
assert_eq!(labels, ["Diffuse Glow…", "Glass…", "Ocean Ripple…"]);
|
||||
assert!(distort.iter().all(|e| e.command.as_deref() == Some("effect.dialog")));
|
||||
let raster: Vec<&str> =
|
||||
entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect();
|
||||
let at = |name: &str| raster.iter().position(|s| *s == name).unwrap();
|
||||
assert!(at("Blur") < at("Distort") && at("Distort") < at("Pixelate"));
|
||||
// The vector Distort & Transform stays its own submenu.
|
||||
assert!(raster.contains(&"Distort & Transform"));
|
||||
}
|
||||
|
||||
/// Effect › Brush Strokes, between Blur and Distort: each filter opens its dialog.
|
||||
#[test]
|
||||
fn effect_menu_lists_the_brush_strokes_filters_between_blur_and_distort() {
|
||||
let app = app();
|
||||
let entries = menus::menu_entries(&app);
|
||||
let strokes: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Brush Strokes"]).collect();
|
||||
let labels: Vec<&str> = strokes.iter().map(|e| e.label.as_str()).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
["Accented Edges…", "Angled Strokes…", "Crosshatch…", "Dark Strokes…", "Ink Outlines…", "Spatter…", "Sprayed Strokes…", "Sumi-e…"]
|
||||
);
|
||||
assert!(strokes.iter().all(|e| e.command.as_deref() == Some("effect.dialog")));
|
||||
let raster: Vec<&str> =
|
||||
entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect();
|
||||
let at = |name: &str| raster.iter().position(|s| *s == name).unwrap();
|
||||
assert!(at("Blur") < at("Brush Strokes") && at("Brush Strokes") < at("Distort"));
|
||||
}
|
||||
|
||||
/// Effect › Texture, between Sharpen and Video: each filter opens its dialog.
|
||||
#[test]
|
||||
fn effect_menu_lists_the_texture_filters_between_sharpen_and_video() {
|
||||
|
||||
@@ -25,7 +25,7 @@ fn app(doc: bool) -> VectorcraftApp {
|
||||
}
|
||||
|
||||
fn bar(app: &VectorcraftApp) -> MenuBar {
|
||||
native_menu::mac_layout(&native_menu::from_tree(app, &menus::menu_tree()), Lang::EN).bar
|
||||
native_menu::mac_layout(app, &native_menu::from_tree(app, &menus::menu_tree()), Lang::EN).bar
|
||||
}
|
||||
|
||||
/// A level's nodes as short names: an item's command (`---` a separator, `[label]` a submenu,
|
||||
@@ -182,8 +182,10 @@ fn every_in_window_item_is_in_the_mac_menu_bar_once() {
|
||||
assert_eq!(m, want, "{key:?} is listed {m} times on the Mac, {n} in the window");
|
||||
}
|
||||
let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE];
|
||||
// Language and Appearance have no in-window menu (synthesized in the App menu, as in PhotoCraft).
|
||||
let synthesized = ["app.language", "window.brightness"];
|
||||
for key in b.keys() {
|
||||
assert!(a.contains_key(key) || system.contains(&key.0.as_str()), "{key:?} isn't an in-window item");
|
||||
assert!(a.contains_key(key) || system.contains(&key.0.as_str()) || synthesized.contains(&key.0.as_str()), "{key:?} isn't an in-window item");
|
||||
}
|
||||
let help = ids(menu(&mac, "Help"));
|
||||
assert!(help.contains(&"help.discord".to_string()) && !help.contains(&"help.about".to_string()), "{help:?}");
|
||||
@@ -211,7 +213,7 @@ fn a_command_keeps_a_system_key_it_is_bound_to() {
|
||||
let mut app = app(true);
|
||||
app.ui.shortcut_overrides.insert("view.grid".into(), "Ctrl+Cmd+H".into());
|
||||
crate::shortcut_editor::sync(&app.ui);
|
||||
let layout = native_menu::mac_layout(&native_menu::from_tree(&app, &menus::menu_tree()), Lang::EN);
|
||||
let layout = native_menu::mac_layout(&app, &native_menu::from_tree(&app, &menus::menu_tree()), Lang::EN);
|
||||
app.ui.shortcut_overrides.clear();
|
||||
crate::shortcut_editor::sync(&app.ui);
|
||||
assert_eq!(find(&layout.bar, native_menu::HIDE).shortcut, None);
|
||||
|
||||
@@ -89,6 +89,10 @@ pub struct Tokens {
|
||||
pub error: Color32,
|
||||
/// Warning marks (a modified linked file).
|
||||
pub warning: Color32,
|
||||
/// Rotation gizmo axes, also labelled X, Y and Z.
|
||||
pub axis_x: Color32,
|
||||
pub axis_y: Color32,
|
||||
pub axis_z: Color32,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
@@ -133,6 +137,9 @@ impl Tokens {
|
||||
bleed: hex(0xf03030),
|
||||
error: hex(0xe34850),
|
||||
warning: hex(0xf0a330),
|
||||
axis_x: hex(0xef6058),
|
||||
axis_y: hex(0x45af63),
|
||||
axis_z: hex(0x378ef0),
|
||||
};
|
||||
match b {
|
||||
Brightness::Dark => base,
|
||||
|
||||
@@ -248,7 +248,7 @@ mod tests {
|
||||
.layers()
|
||||
.filter(|(l, _)| l.order != Order::Foreground)
|
||||
.flat_map(|(_, ws)| ws.iter())
|
||||
.filter(|w| w.sense.senses_click() && w.rect.top() < 44.0 && !skip.contains(&w.id))
|
||||
.filter(|w| w.sense.senses_click() && w.rect.top() < 32.0 && !skip.contains(&w.id))
|
||||
.map(|w| w.rect)
|
||||
.collect()
|
||||
})
|
||||
@@ -263,20 +263,22 @@ mod tests {
|
||||
let out = w.frame(vec![]);
|
||||
let [min, max, close] = CAPTIONS.map(|c| w.caption(c));
|
||||
assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}");
|
||||
assert!(close.height() >= 42.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
|
||||
assert!(close.height() >= 30.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
|
||||
assert_eq!((min.right(), max.right()), (max.left(), close.left()));
|
||||
let widgets = w.bar_widgets();
|
||||
assert!(widgets.len() >= 12, "home, nine menus, workspace switcher, search: {widgets:?}");
|
||||
assert!(widgets.len() >= 11, "home, eight menus, workspace switcher, search: {widgets:?}");
|
||||
for (i, a) in widgets.iter().enumerate() {
|
||||
assert!(a.right() <= min.left() - 8.0, "{a:?} runs into the caption buttons at {width}");
|
||||
for b in &widgets[i + 1..] {
|
||||
assert!(!a.shrink(0.5).intersects(b.shrink(0.5)), "{a:?} overlaps {b:?} at {width}");
|
||||
}
|
||||
}
|
||||
// Wide windows show the whole group; narrow ones drop Discord and shorten the search.
|
||||
// Wide windows show the whole group; narrow ones drop Discord, then the search icon.
|
||||
let texts: Vec<String> = texts(&out).into_iter().map(|(t, _)| t).collect();
|
||||
let has = |s: &str| texts.iter().any(|t| t == s);
|
||||
assert_eq!((has("Discord"), has("Search commands and tools")), (width > 1000.0, width > 1000.0), "{width}: {texts:?}");
|
||||
assert_eq!(has("Discord"), width > 1000.0, "{width}: {texts:?}");
|
||||
// Search is an icon only (as in PhotoCraft): its label lives in the tooltip.
|
||||
assert!(!has("Search commands and tools"), "{width}: {texts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +316,7 @@ mod tests {
|
||||
fn empty_bar_space_drags_and_double_click_maximizes() {
|
||||
let mut w = Win::new(1440.0);
|
||||
w.frame(vec![]);
|
||||
let empty = pos2(700.0, 22.0);
|
||||
let empty = pos2(700.0, 16.0);
|
||||
assert!(w.bar_widgets().iter().all(|r| !r.contains(empty)), "the probe point must be empty bar space");
|
||||
let cmds = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]);
|
||||
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, written from the workspace as it is on origin/main `01e165af`) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft is built today. The design rationale lives in the local, gitignored `plan/architecture.md`;
|
||||
this file describes what is committed. Day-to-day rules (clean-room, never crash, quality gates) are in
|
||||
[`AGENTS.md`](../AGENTS.md) and [development.md](development.md).
|
||||
|
||||
## Shape
|
||||
|
||||
A Cargo workspace of 23 library crates under `crates/`, three apps under `apps/` and `xtask`, ~354k lines of Rust,
|
||||
pure Rust (no handwritten JS/TS). The same engine runs the desktop app, the browser build (WASM), the headless
|
||||
CLI and the MCP server.
|
||||
|
||||
## Layers
|
||||
|
||||
`cargo xtask layers` (part of `cargo xtask ci`) enforces that a crate depends only on lower layers, and that
|
||||
nothing below L6 depends on a UI toolkit (egui, eframe, winit, rfd). Table: `xtask/src/layers.rs`.
|
||||
|
||||
| Layer | Crates | Role |
|
||||
|---|---|---|
|
||||
| L0 | `geom`, `color`; `affinity` (standalone, no workspace deps) | curves and transforms (on `kurbo`), colour models, ICC and conversions; the Affinity reader |
|
||||
| L1 | `doc` | the document model: artboards, layers, objects, appearance, text, symbols; pure data + serde, structural sharing |
|
||||
| L2 | `pathops`, `brush`, `trace`, `text`, `effects`, `plugins`, `three-d` | booleans/Shape Builder/offset/outline; brushes; Image Trace; fonts, shaping (HarfRust, skrifa), bidi and layout; live effects and the pixel-filter pipeline; sandboxed WebAssembly plug-ins (wasmi); 3D surfaces of revolution, projection and shading (`three-d`, on `geom` only) |
|
||||
| L3 | `render`, `svg`, `pdf`, `format`, `eps`, `cad`, `metafile` | document to pixels (`vello_cpu`, multithreaded); SVG, PDF, native JSON, EPS/PostScript (with its own interpreter), DXF, EMF/WMF readers and writers |
|
||||
| L4 | `tools` | tool state machines: pointer events in, commands and overlays out (Pen, Direct Selection, smart guides…) |
|
||||
| L5 | `engine` | sessions, history (unlimited undo by structural sharing), selection, the command registry (~710 commands), file I/O, the panic guard |
|
||||
| L6 | `ui-egui`, `mcp` | the egui front end (menus, panels, dialogs, canvas, i18n); the MCP server (JSON-RPC over stdio) |
|
||||
| L7 | `apps/vectorcraft`, `apps/vectorcraft-cli`, `apps/vectorcraft-web`, `xtask` | desktop app (eframe, wgpu), CLI (`run`, `convert`, `info`, `bench`, `perf`, `mcp`), browser runner (WebGPU, WebGL2 fallback), build tooling |
|
||||
|
||||
`testkit` holds test helpers (fixtures, generators, render comparison) and is only a dev-dependency.
|
||||
|
||||
## Everything is a command
|
||||
|
||||
Every user-visible action is a `CommandSpec` in `crates/engine/src/cmd/*` (id, label, menu path, shortcut,
|
||||
params doc, `enabled`, `run`, journal flag); UI-only commands are `UI_COMMANDS` in
|
||||
`crates/ui-egui/src/menus.rs`. Tools emit commands (begin, preview, commit). Menus, the command palette, Actions,
|
||||
the control channel, the CLI and MCP all reach the same registry, so anything a user can do an agent can do.
|
||||
`command.batch` runs several as one undo step.
|
||||
|
||||
## Rendering
|
||||
|
||||
The canvas is rasterized on the CPU by `vello_cpu` on worker threads, off the UI thread, with caches per object
|
||||
and per effect; the GPU (wgpu: Metal, DX12, Vulkan/GL; WebGPU/WebGL2 on the web) only composites the result. Raster
|
||||
effects are rendered offscreen per object and cached while panning. See
|
||||
[hardware-parity.md](hardware-parity.md).
|
||||
|
||||
## Files
|
||||
|
||||
The native `.vectorcraft` format is versioned, compressed JSON with embedded images, saved atomically, readable
|
||||
down to v1 and from the old `.drawcraft` name. The `FORMATS` table in `crates/engine/src/cmd/fileio/mod.rs`
|
||||
lists every format read or written; importers are fuzzed (`crates/engine/tests/import_fuzz.rs`). See
|
||||
[file-format-parity.md](file-format-parity.md).
|
||||
|
||||
## Agent control
|
||||
|
||||
- **Control channel:** JSON lines on a local TCP port (`--control 7979`), with real egui pointer and keyboard
|
||||
injection and screenshots ([control-protocol.md](control-protocol.md)).
|
||||
- **MCP:** `vectorcraft-cli mcp`, attached to a running app or headless ([mcp.md](mcp.md)).
|
||||
- **Sandboxing:** `--automation-read-root` / `--automation-write-root` confine what agents read and write.
|
||||
|
||||
## Robustness
|
||||
|
||||
No panics in shipped code (workspace clippy lints ban `unwrap`, `expect`, `panic!` and friends outside tests);
|
||||
`vectorcraft_engine::guard` catches a panic at every entry point and rolls the document back; Data Recovery
|
||||
saves copies in the background. See [development.md](development.md#robustness-vectorcraft-never-crashes).
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Added the `three-d` crate |
|
||||
| 2026-10-10 | major | First version, from the workspace, the layering table and the existing docs |
|
||||
@@ -208,8 +208,10 @@ Tile Edge Color: Object → Pattern → Tile Edge Color… (`ui.tileEdgeColor`)
|
||||
Window title bar: on Windows and Linux the window has no OS decorations and the app bar is the title bar. Its
|
||||
caption buttons (Minimize, Maximize/Restore, Close) sit at the bar's right end, 46 pt each; they are window chrome,
|
||||
not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (the same `saveChanges` questions for
|
||||
modified documents), and empty bar space and the 5 pt window edges move and resize the window. macOS and the web
|
||||
build are unchanged.
|
||||
modified documents), and empty bar space and the 5 pt window edges move and resize the window. Preferences › User
|
||||
Interface › System Title Bar (`systemTitleBar`, read when the app starts) gives the window its OS decorations back
|
||||
instead: the OS title then shows the active document (`*name — VectorCraft` with unsaved changes) and the app bar
|
||||
drops its brand mark. On macOS the window buttons are centred on the app bar; the web build is unchanged.
|
||||
|
||||
Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy
|
||||
forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer);
|
||||
|
||||
+8
-5
@@ -57,6 +57,8 @@ The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (
|
||||
- **Automatic on Linux and the BSDs** is the GPU that drives the panel or a monitor, then the system's order: Mesa's Vulkan device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid laptops; `DRI_PRIME` and `MESA_VK_DEVICE_SELECT` steer it). A Wayland compositor may not accept frames from another GPU: on a desktop whose compositor ran on an NVIDIA GPU, rendering on the Ryzen's integrated GPU made KWin end the window's connection ("importing the supplied dmabufs failed") and 0.5.0 crashed at startup (#502).
|
||||
- 0.5.0 saved `powerSaving`, its default, for everyone; it reads as `automatic`.
|
||||
- **If the window fails on its adapter while starting up** (an error from wgpu, or a panic inside wgpu or egui-wgpu during the first frames), the app starts again without that adapter and tries the next one, telling which in the status bar; the run that failed keeps its log as `vectorcraft.1.log`. On Unix the new app replaces the process (an AppImage stays mounted); on Windows it starts beside it. Adapters are told apart by backend and PCI ids, or by name where the backend reports no ids (Metal lists every GPU of a dual-GPU Mac as `0000:0000`, #651). The adapters left out travel in `VECTORCRAFT_GPU_SKIP` (`backend:vendor:device`, such as `Vulkan:1002:164e`), one more on each restart, so the restarts end. When no adapter is left, the app logs why and exits with an error instead of panicking.
|
||||
- **If the window's first frame never reaches the screen** within 20 seconds of the app being created (the files named at the start opened), the adapter counts as hanging and the app starts again without it in the same way (#964: High Performance on a hybrid laptop left the app running in the background with no window and nothing in the log). A window minimized at the start isn't counted. When the processor that hung was chosen in Preferences, the status bar points to Automatic.
|
||||
- **To recover from a Graphics Processor choice that doesn't work** without editing files: start once with `WGPU_POWER_PREF=low` (or `WGPU_ADAPTER_NAME=…`), which overrides the preference for that start while settings still save, and set Graphics Processor back to Automatic. `VECTORCRAFT_NO_PREFS=1` also starts without it, but saves nothing.
|
||||
- **Environment variables** for when the choice still goes wrong, all read at startup and stronger than the preference:
|
||||
- `WGPU_POWER_PREF`: `low`, `high` or `none` (the system's order).
|
||||
- `WGPU_ADAPTER_NAME`: part of an adapter's name, any case (`WGPU_ADAPTER_NAME=nvidia`, `=radv`, `=intel`); the names are in the log.
|
||||
@@ -86,9 +88,9 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`.
|
||||
|
||||
## macOS: the menu bar
|
||||
|
||||
On macOS the menus live in the system menu bar, not in the window's app bar (which keeps the brand mark, Home, the search box and the workspace switcher next to the traffic lights). `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps them in the window, as on Windows, Linux and the web; so does a menu bar AppKit refuses to build (logged). `ui.inspect` says which with `nativeMenuBar`.
|
||||
On macOS the menus live in the system menu bar, not in the window's app bar (which keeps the brand mark, Home, the search icon and the workspace switcher next to the traffic lights, centred on the bar by `apps/vectorcraft/src/mac_window.rs`). `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps them in the window, as on Windows, Linux and the web; so does a menu bar AppKit refuses to build (logged). `ui.inspect` says which with `nativeMenuBar`.
|
||||
|
||||
- **One menu model.** `crates/ui-egui/src/native_menu.rs` (platform-free, tested on every platform) builds the menu bar from the in-window menus (`menus::menu_tree`, each item through `menus::entry`, which the in-window menus draw too) and lays it out the Mac way: the VectorCraft menu holds About, Settings ▸ (one item per Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own `app.quit`, so unsaved documents are asked about); those leave Edit and Help. Window starts with Minimize ⌃⌘M and Zoom and ends with Bring All to Front, and Help is the system's Help menu (its search field). A system key a command (or a user's shortcut) already has stays the command's, and the log says so.
|
||||
- **One menu model.** `crates/ui-egui/src/native_menu.rs` (platform-free, tested on every platform) builds the menu bar from the in-window menus (`menus::menu_tree`, each item through `menus::entry`, which the in-window menus draw too) and lays it out the Mac way: the VectorCraft menu holds About, Settings ▸ (one item per Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own `app.quit`, so unsaved documents are asked about); those leave Edit and Help. The in-window bar itself never shows the VectorCraft menu (it starts at File, as in Illustrator, on every platform): Language and Appearance have no in-window menu and are synthesized in `mac_layout`, and Quit is File › Exit in the window, relabelled to Quit in the App menu. Window starts with Minimize ⌃⌘M and Zoom and ends with Bring All to Front, and Help is the system's Help menu (its search field). A system key a command (or a user's shortcut) already has stays the command's, and the log says so.
|
||||
- **The native side** is `apps/vectorcraft/src/mac_menu.rs`: muda (`=0.21.1`, default features off, macOS only, no `unsafe`) builds the `NSMenu`s. Labels, enabled and checked are updated in place when anything they show may have changed (a command ran, the document, its history or selection changed, a click or a key press); a change of structure (a recent file, the language, a shortcut) rebuilds the menus.
|
||||
- **Keys stay with egui.** AppKit runs a menu's key equivalents before the window sees the key, so the item's key goes back to egui as the key press it was (⌘X/⌘C/⌘V as egui's Cut/Copy/Paste events), through `raw_input_hook`: `shortcuts::handle` runs it once, user shortcuts apply, and a focused text field keeps ⌘A, ⌘C, ⌘V and ⌘Z. A click runs the item like an in-window click. Bare keys and ⌥/⇧ chords are never key equivalents (they type).
|
||||
- Built and clippy-checked for `aarch64-apple-darwin` and `x86_64-apple-darwin` from any host: `cargo clippy -p vectorcraft --target aarch64-apple-darwin --all-targets -- -D warnings`.
|
||||
@@ -166,7 +168,8 @@ always use the English ids and labels, so agents and scripts never see translate
|
||||
Languages shipped: English (`en`, the source), Traditional Chinese (`zh-hant`, complete, in the vocabulary used
|
||||
in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` locales all resolve to it), Simplified Chinese (`zh-hans`,
|
||||
complete, in the vocabulary used in mainland China; `zh-CN`, `zh-SG`, `zh-Hans-*` and a bare `zh` resolve to it,
|
||||
so the two scripts never mix), Japanese (`ja`, complete), Spanish (`es`, complete, in neutral
|
||||
so the two scripts never mix), Japanese (`ja`, complete), German (`de`, complete; `de-DE`, `de-AT`, `de-CH` and every
|
||||
other `de-*` locale resolve to it), Spanish (`es`, complete, in neutral
|
||||
international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or `es-419` resolves to it), French (`fr`,
|
||||
complete; `fr-FR`, `fr-BE`, `fr-CA`, `fr-CH` and every other `fr-*` locale resolve to it), Italian (`it`,
|
||||
complete; `it-IT`, `it-CH` and every other `it-*` locale resolve to it), Russian (`ru`, complete; every `ru-*`
|
||||
@@ -182,8 +185,8 @@ locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Ukrainian (`uk`, com
|
||||
- The shared widgets (`widgets::check`, `dropdown`, `menu_item`, the buttons, `label_row`, tooltips of
|
||||
`icon_button`…), the menus, the dock, the toolbar, the dialog frame and `panels::empty_state` translate
|
||||
the text they are given, so a panel mostly needs its literals wrapped in `tl!` to be covered by the tests.
|
||||
- The language is VectorCraft › Language (the `app.language` UI command, `{lang: auto|<code>}`) or Edit ›
|
||||
Preferences › User Interface › Language; both set the `interfaceLanguage` preference (`auto` or a language
|
||||
- The language is Edit › Preferences › User Interface › Language or, on macOS, VectorCraft › Language (the
|
||||
`app.language` UI command, `{lang: auto|<code>}`); both set the `interfaceLanguage` preference (`auto` or a language
|
||||
code; `auto` follows the system locale: `VECTORCRAFT_LOCALE`, then `LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE`,
|
||||
the macOS preferred languages, the Windows user locale; on the web, `?lang=<code>` in the address, then
|
||||
the browser's `navigator.languages`). The Preferences dialog previews the chosen language before OK.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Effects parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, counted from the effects catalogue) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
|
||||
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
|
||||
(`crates/ui-egui/src/menus.rs`). Counts are **measured** from the catalogue; "depth" notes are estimated.
|
||||
Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](gaps.md).
|
||||
|
||||
## Summary
|
||||
|
||||
| Group | Illustrator | VectorCraft | % | Remaining (h) |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Illustrator effects (vector and stylize) | 50 | 44 | 88% | 6–10 |
|
||||
| Photoshop effects (raster, Effect Gallery) | 57 | 28 | 49% | 24–40 |
|
||||
| 3D and Materials | 4 (+3 Classic) | 1 (initial Revolve) | ~8% | 45–75 |
|
||||
| Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — |
|
||||
|
||||
## Illustrator effects
|
||||
|
||||
| Submenu | Items | Ours | Missing |
|
||||
|---|---|---:|---|
|
||||
| Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — |
|
||||
| Crop Marks | Crop Marks | 1/1 | — |
|
||||
| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885) |
|
||||
| Path | Offset Path, Outline Object, Outline Stroke | 2/3 | Outline Object |
|
||||
| Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 10/13 | Hard Mix, Soft Mix, Trap |
|
||||
| Rasterize | Rasterize… (as a live effect) | 0/1 | the live effect (Object › Rasterize exists) |
|
||||
| Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | — |
|
||||
| SVG Filters | Apply SVG Filter… and the filter list | 0/1 | the whole submenu (SVG filters on import round-trip already) |
|
||||
| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes |
|
||||
|
||||
Document Raster Effects Settings exists.
|
||||
|
||||
## Photoshop effects
|
||||
|
||||
| Submenu | Items | Ours | Missing |
|
||||
|---|---|---:|---|
|
||||
| Artistic | Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting, Watercolor | 0/15 | all |
|
||||
| Blur | Gaussian Blur, Radial Blur, Smart Blur | 3/3 | Smart Blur's Edge Only and Overlay Edge modes |
|
||||
| Brush Strokes | Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e | 8/8 | — |
|
||||
| Distort | Diffuse Glow, Glass, Ocean Ripple | 3/3 | Glass's Load Texture |
|
||||
| Pixelate | Color Halftone, Crystallize, Mezzotint, Pointillize | 4/4 | — |
|
||||
| Sharpen | Unsharp Mask | 1/1 | — |
|
||||
| Sketch | Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges, Water Paper | 0/14 | all |
|
||||
| Stylize | Glowing Edges | 1/1 | — |
|
||||
| Texture | Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer | 6/6 | Texturizer's Load Texture |
|
||||
| Video | De-Interlace, NTSC Colors | 2/2 | — |
|
||||
| Effect Gallery | the combined browser and stack dialog | 0/1 | all |
|
||||
|
||||
The pixel-filter pipeline (`vectorcraft_effects::pixel`) is in place: each new filter is one function, a
|
||||
catalogue row, a menu entry and translations. Measured throughput: 0.7–1.2 agent hours per filter
|
||||
(see [calibration](target-app-parity.md#calibration)), so the 29 missing filters are ~20–35 h and the Effect
|
||||
Gallery dialog another 4–6 h. These parallelize well across agents.
|
||||
|
||||
## 3D and Materials
|
||||
|
||||
| Item | Ours |
|
||||
|---|---|
|
||||
| Revolve | initial: live, editable profile, rotation and light gizmos, flat shading, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
|
||||
| Extrude & Bevel, Inflate, Rotate | none (menu stubs) |
|
||||
| Materials panel, lighting, ray-traced render | none |
|
||||
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none |
|
||||
| Turntable (30.0 beta) | none |
|
||||
| Export 3D objects (OBJ, USDA, glTF) | none |
|
||||
|
||||
The `three-d` crate (depends only on `geom`) builds, rotates, projects, shades and depth-sorts the surfaces;
|
||||
the effects crate turns them into art. What's left (Extrude & Bevel, Inflate, Rotate, caps, materials, ray-traced
|
||||
rendering, Turntable, 3D export) is 45–75 h, still the largest single gap.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Brush Strokes landed (8 filters): 28/57 Photoshop effects |
|
||||
| 2026-10-10 | minor | Initial Revolve landed (#846) |
|
||||
| 2026-10-10 | major | First checklist, counted from the effects catalogue (20/57 Photoshop effects, 44/50 Illustrator effects) |
|
||||
@@ -0,0 +1,81 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, from the `FORMATS` table and Illustrator 2026's documented formats) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public
|
||||
documentation (helpx "file formats" and Save/Export pages), not from its installed bundle: the clean-room rule
|
||||
forbids reading the install. Part of [target-app-parity.md](target-app-parity.md).
|
||||
|
||||
**Fidelity** is estimated unless a test is named. Test sources: unit and property tests per crate
|
||||
(`crates/svg/tests`, `crates/pdf/tests`, `crates/format/tests/prop_format.rs`), import fuzzing
|
||||
(`crates/engine/tests/import_fuzz.rs`), the Affinity corpus (`cargo xtask corpus --affinity`, 51 pinned public
|
||||
files, CI job `affinity-corpus.yml`). There is **no real-world Illustrator corpus**: test inputs are written
|
||||
from the public specifications, and users' own `.ai` files are used locally only.
|
||||
|
||||
## Summary
|
||||
|
||||
| | Illustrator | VectorCraft | Notes |
|
||||
|---|---:|---:|---|
|
||||
| Formats opened or placed | ~30 | 22 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, PCX, Pixar, RTF/Word, PS |
|
||||
| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written as PDF-compatible only (no Illustrator editing data) |
|
||||
| Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD |
|
||||
| **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting |
|
||||
|
||||
## The main format: `.ai`
|
||||
|
||||
The format that decides beta. Illustrator's `.ai` is a PDF (or, for old files, PostScript) plus Illustrator's
|
||||
private editing data. VectorCraft:
|
||||
|
||||
- **Reads** the PDF page (vectors, text, images, colour, transparency, layers via optional content) and the
|
||||
*structure* of the editing data (below). Opens Illustrator EPS the same way through its own PostScript
|
||||
Level 3 interpreter.
|
||||
- **Writes** a PDF-compatible `.ai` (Save As › `ai`): Illustrator opens it as plain art. It never writes
|
||||
Illustrator's private editing data, which is undocumented.
|
||||
- **Known failures in users' files:** layer structure flattened or multiplied (#951: 109 layers instead of
|
||||
14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction
|
||||
lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects,
|
||||
brushes, symbols and pattern fills come in as their drawn look (#637), legacy `.ai` without the prolog not
|
||||
read.
|
||||
- Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible only).
|
||||
Closing the open-side issues: 10–20 h with users' files.
|
||||
|
||||
### Illustrator's private editing data
|
||||
|
||||
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
|
||||
## Format by format
|
||||
|
||||
| Format | Illustrator | Ours: open/place | Ours: save/export | Fidelity and tests |
|
||||
|---|---|---|---|---|
|
||||
| VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`) |
|
||||
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure) | PDF-compatible only | see above; no real-file corpus |
|
||||
| Illustrator template `.ait` | open, save | yes | no (save as `.vctemplate`) | |
|
||||
| PDF | open, place, save | yes (layers, masks, editable text, security) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; PDF text placement issues in some files (#722) |
|
||||
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type and CCITTFax images not read; #505 |
|
||||
| PostScript `.ps` | open | no | via Print to PostScript | |
|
||||
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests` |
|
||||
| DXF | open, export | yes | yes (R12–2018) | |
|
||||
| DWG | open, export | no | no | no open spec; DXF instead |
|
||||
| EMF / WMF | open, export | yes | yes | |
|
||||
| PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing |
|
||||
| PSB | open | yes (merged image) | no | |
|
||||
| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes | Targa import missing |
|
||||
| WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | |
|
||||
| HEIC / HEIF | open, place | no | no | |
|
||||
| JPEG 2000 | open, place | no | no | |
|
||||
| PICT, PCX, Pixar PXR | open, place (legacy) | no | no | rarely used |
|
||||
| FreeHand `.fh*` | open (legacy) | no | no | |
|
||||
| CorelDRAW `.cdr` | open | no | no | |
|
||||
| Text `.txt` | place, export | place (Text Import Options) | yes | |
|
||||
| RTF / Word `.doc`/`.docx` | place | no | no | |
|
||||
| CSS | export (CSS Properties) | — | yes | |
|
||||
| OBJ / USDA / glTF | 3D export | — | no | needs 3D (gap G3) |
|
||||
| Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) |
|
||||
| Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | First checklist; moved the `.ai` editing-data scope from the ROADMAP's "Out of scope" here; added the user-reported `.ai` failures |
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (moved from the ROADMAP's "Where we're lacking", re-ranked, added file-format, stability, hardware, localization, ecosystem and AI gaps) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
|
||||
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
|
||||
progress on, write a plan, and update the entry (and [target-app-parity.md](target-app-parity.md)) in the same PR.
|
||||
|
||||
Gap ids are stable (PRs say "Part of gap 2"); the rank can change. Hours are Opus 5.5 agent wall-clock hours for
|
||||
one agent, estimated unless marked; calibration in
|
||||
[target-app-parity.md](target-app-parity.md#calibration). **Beta** marks the gaps that must close before the beta
|
||||
stage ([ROADMAP.md](../ROADMAP.md)).
|
||||
|
||||
## Ranked
|
||||
|
||||
| Rank | Gap | Hours | Beta | Doc |
|
||||
|---:|---|---:|:---:|---|
|
||||
| 1 | [G1 Interaction fidelity](#g1-interaction-fidelity) | 60–90 | yes | [ui-parity.md](ui-parity.md) |
|
||||
| 2 | [G9 Illustrator `.ai` and EPS files from real users](#g9-illustrator-ai-and-eps-files-from-real-users) | 10–20 | yes | [file-format-parity.md](file-format-parity.md) |
|
||||
| 3 | [G10 Launch and stability on real machines](#g10-launch-and-stability-on-real-machines) | 20–35 | yes | — |
|
||||
| 4 | [G8 Hardening at scale](#g8-hardening-at-scale) | 30–50 | yes (part) | — |
|
||||
| 5 | [G11 Correctness bugs in core tools](#g11-correctness-bugs-in-core-tools) | 8–15 | yes | [target-app-parity.md](target-app-parity.md) |
|
||||
| 6 | [G4 Advanced type](#g4-advanced-type) | 34–47 | core part | [type-parity.md](type-parity.md) |
|
||||
| 7 | [G12 Preferences and panels that don't act yet](#g12-preferences-and-panels-that-dont-act-yet) | 13–20 | yes | [ui-parity.md](ui-parity.md) |
|
||||
| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 24–40 | no | [effects-parity.md](effects-parity.md) |
|
||||
| 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 15–25 | no | [target-app-parity.md](target-app-parity.md) |
|
||||
| 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 8–16 | macOS pressure | [hardware-parity.md](hardware-parity.md) |
|
||||
| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 17–28 | no | [target-app-parity.md](target-app-parity.md) |
|
||||
| 12 | [G3 3D and Materials](#g3-3d-and-materials) | 45–75 | no | [effects-parity.md](effects-parity.md) |
|
||||
| 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) |
|
||||
| 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 8–14 | no | [target-app-parity.md](target-app-parity.md) |
|
||||
| 15 | [G15 Performance budgets and a GPU renderer](#g15-performance-budgets-and-a-gpu-renderer) | 15–25 | budgets only | [hardware-parity.md](hardware-parity.md) |
|
||||
| 16 | [G16 Localization: the five missing key languages](#g16-localization-the-five-missing-key-languages) | 70–110 | no | [localization-parity.md](localization-parity.md) |
|
||||
| 17 | [G17 Ecosystem: third-party plug-ins](#g17-ecosystem-third-party-plug-ins) | 20–45 | no | — |
|
||||
| 18 | [G18 Generative AI features](#g18-generative-ai-features) | 30–60 | no | — |
|
||||
| 19 | [G19 Accessibility](#g19-accessibility) | 15–25 | no | [ui-parity.md](ui-parity.md) |
|
||||
|
||||
**Alpha blockers:** none. Every core workflow passes the [alpha gate](roadmap.md#alpha-gate); G9 is the only
|
||||
partial one, and it blocks beta, not alpha.
|
||||
|
||||
**To beta:** G1, G9, G10, G11, G12, the corpus and QA part of G8, the type-core part of G4, macOS pen pressure
|
||||
from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall clock with 4–6 agents.
|
||||
|
||||
## G1 Interaction fidelity
|
||||
|
||||
- **Missing:** a tool-by-tool, panel-by-panel pass over modifiers, cursors, the Properties panel per context,
|
||||
isolation mode and small behaviours, against the public tool and shortcut documentation. Itemized in
|
||||
[ui-parity.md](ui-parity.md). The Shaper's construction-mode widget and gesture refinements remain.
|
||||
- **Evidence:** no side-by-side session has ever happened (the clean-room rule forbids running Illustrator, so
|
||||
the pass works from the documentation); users keep reporting small differences: #991, #975, #973, #955, #908.
|
||||
- **Impact:** a power user notices this in the first minutes and stops trusting the tool.
|
||||
- **Estimate:** 60–90 h; splits well by tool across agents.
|
||||
|
||||
## G9 Illustrator `.ai` and EPS files from real users
|
||||
|
||||
- **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead
|
||||
of 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction
|
||||
lines (#779), open speed on large files (#758), legacy `.ai` without the prolog, live effects, brushes, symbols
|
||||
and pattern fills as live objects (#637). Writing Illustrator's own editing data is out of scope
|
||||
(undocumented); Save As `.ai` stays PDF-compatible.
|
||||
- **Evidence:** the issues above; 28 of the 350 issues are about `.ai`/EPS (about 22 people, the most common single
|
||||
format theme), 20 fixed, and the 8 open ones are about structure (layers, guides, speed, live objects), not missing
|
||||
art. No real-file `.ai` corpus (users' files may be used locally, never committed).
|
||||
- **Impact:** blocks anyone who hands files back to Illustrator users. Opening and reworking their own archive
|
||||
works, with layer cleanup in some files. This is the main-format gap that keeps the stage at alpha.
|
||||
- **Estimate:** 10–20 h with users' files to test against.
|
||||
|
||||
## G10 Launch and stability on real machines
|
||||
|
||||
- **Missing:** the app not starting or staying in the background on some Windows 11 machines (#858, #964 fixed
|
||||
2026-10-10, #620, #818), slow pointer on a Linux AppImage (#575), macOS paste from other apps (#954), a
|
||||
web-free crash/hang reporter, a field record.
|
||||
- **Evidence:** open issues; the never-crash lints and `guard` stop panics, not driver, windowing or packaging
|
||||
failures.
|
||||
- **Impact:** an app that doesn't start on someone's machine is 0% for them.
|
||||
- **Estimate:** 20–35 h, needs reporters' help on hardware we don't have.
|
||||
|
||||
## G8 Hardening at scale
|
||||
|
||||
- **Missing:** a corpus of real-world SVG/PDF/EPS files; more Affinity versions and platforms (51 pinned files
|
||||
now, including 28 original Affinity 3.2.3 feature probes; large interacting documents and complex typography
|
||||
missing); Windows, Linux and browser QA by hand; packaging polish (Homebrew cask #588, Flathub #554, update
|
||||
checks #615).
|
||||
- **Impact:** unknown failures on users' files.
|
||||
- **Estimate:** 30–50 h (the corpus and QA part blocks beta).
|
||||
|
||||
## G11 Correctness bugs in core tools
|
||||
|
||||
- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Asset Export
|
||||
anti-aliasing ghost borders (#983); Transform effect dialog's invisible checkboxes and missing options (#885);
|
||||
PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834).
|
||||
- **Impact:** wrong output in everyday work.
|
||||
- **Estimate:** 8–15 h.
|
||||
|
||||
## G4 Advanced type
|
||||
|
||||
- **Missing:** hyphenation and justification options, Optical Margin Alignment (type core); CJK vertical
|
||||
composition (ruby, mojikumi sets and dialog, proportional vertical metrics #966, kinsoku settings #633, manual
|
||||
tate-chu-yoko), Middle Eastern features beyond bidi, variable font axes, spell check with an open dictionary,
|
||||
Touch Type, Retype, Snap to Glyph. Itemized in [type-parity.md](type-parity.md).
|
||||
- **Impact:** professional typography and Japanese/Chinese publishing.
|
||||
- **Estimate:** 34–47 h (type core 14–19 h blocks beta).
|
||||
|
||||
## G12 Preferences and panels that don't act yet
|
||||
|
||||
- **Missing:** ~17 Preferences options stored but not acted on (#394: Highlight Alternate Glyphs, Open
|
||||
Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…), Properties panel per
|
||||
context, context menus beyond the canvas, Layers panel row menus and Collect for Export, SVG Interactivity.
|
||||
- **Impact:** settings that silently do nothing erode trust.
|
||||
- **Estimate:** 13–20 h.
|
||||
|
||||
## G2 Photoshop-style raster effects and the Effect Gallery
|
||||
|
||||
- **Missing:** 29 of 57 filters (all of Artistic and Sketch), the Effect Gallery dialog, Load
|
||||
Texture for Glass and Texturizer, Smart Blur's Edge Only and Overlay Edge. Measured in
|
||||
[effects-parity.md](effects-parity.md).
|
||||
- **Done:** the pixel-filter pipeline and 28 filters (Blur, Brush Strokes, Distort, Pixelate, Sharpen, Stylize,
|
||||
Texture, Video), Brush Strokes on 2026-10-11.
|
||||
- **Estimate:** 24–40 h (measured 0.7–1.2 h per filter); parallelizes well.
|
||||
|
||||
## G13 Illustrator 2026 (30.x) additions
|
||||
|
||||
- **Missing:** gradient dither and perceptual blending, gradient presets (30.0); tangent and perpendicular
|
||||
snapping and the consolidated snapping options (30.0); artboard locking, artboard colours and managing
|
||||
artboards from the canvas (30.0); the Blend panel with ease in/out and separate colour acceleration (30.7);
|
||||
TIFF in Export for Screens (30.2); the Dimension tool (28.x).
|
||||
- **Evidence:** Adobe's 30.0 and 30.7 release announcements, see
|
||||
[target-app-parity.md](target-app-parity.md#how-this-was-measured-2026-10-10).
|
||||
- **Estimate:** 15–25 h.
|
||||
|
||||
## G14 Pen hardware beyond Windows pressure
|
||||
|
||||
- **Missing:** pen pressure on macOS (#852) and Wayland (#491, #764) — winit 0.30 reports none there; tilt,
|
||||
bearing and barrel rotation for brushes (6D Art Pen, #372); the eraser end.
|
||||
- **Estimate:** 8–16 h, partly waiting on winit 0.31; needs pen hardware to verify.
|
||||
|
||||
## G5 Brushes, symbols and libraries
|
||||
|
||||
- **Missing:** the Art brush's Overlap, auto-generated Pattern brush corners, new tile art in Pattern Brush
|
||||
Options, the Key Color eyedropper; original brush, symbol and graphic-style libraries generated in code (never
|
||||
Adobe's); dynamic symbols; Start Global Edit.
|
||||
- **Estimate:** 17–28 h.
|
||||
|
||||
## G3 3D and Materials
|
||||
|
||||
- **Done:** an initial live Revolve in the `three-d` crate (#846, from the contributor who offered in #605).
|
||||
- **Missing:** Extrude & Bevel, Inflate and Rotate, caps, materials and better lighting, exact visibility for
|
||||
intersecting surfaces, 3D Classic, Turntable, 3D export (OBJ, USDA, glTF).
|
||||
- **Impact:** specialist, but a visible menu of stubs.
|
||||
- **Estimate:** 45–75 h, the largest single gap; plan in `plan/` before coding.
|
||||
|
||||
## G7 Views and windows
|
||||
|
||||
- **Missing:** multiple windows and Window › Arrange (Cascade, Tile, Float in Window, Consolidate All Windows),
|
||||
panels on another monitor, New View/Edit Views, global and video rulers.
|
||||
- **Estimate:** 15–25 h.
|
||||
|
||||
## G6 Automation and scripting
|
||||
|
||||
- **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, image and graph
|
||||
variable kinds, dataset import/export. Actions, Variables (text and visibility) and full agent control over
|
||||
MCP are done.
|
||||
- **Estimate:** 8–14 h.
|
||||
|
||||
## G15 Performance budgets and a GPU renderer
|
||||
|
||||
- **Missing:** an idle-machine run of `vectorcraft-cli perf` (last: 2026-10-01 on a loaded machine, 290 ms for
|
||||
a 50k-path fit against a 16 ms budget), fixes for any budget missed, dirty-region rendering, the GPU backend
|
||||
spike (M5).
|
||||
- **Estimate:** 15–25 h (the budget run and fixes block beta).
|
||||
|
||||
## G16 Localization: the five missing key languages
|
||||
|
||||
- **Missing:** Hindi, Arabic, Indonesian, Korean, Vietnamese catalogs; a right-to-left interface and interface
|
||||
shaping for Arabic and Devanagari; messages for Simplified Chinese and Brazilian Portuguese; native review of
|
||||
every catalog. Measured in [localization-parity.md](localization-parity.md).
|
||||
- **Estimate:** 70–110 h plus human review.
|
||||
|
||||
## G17 Ecosystem: third-party plug-ins
|
||||
|
||||
- **Missing:** Illustrator's ecosystem is a C++ plug-in SDK and a large commercial plug-in market. VectorCraft
|
||||
has sandboxed WebAssembly plug-ins for object filters and live effects ([plugins.md](plugins.md)) and MCP;
|
||||
missing are tool plug-ins, panel plug-ins, a plug-in directory and any third-party plug-ins (#710).
|
||||
- **Estimate:** 20–45 h for the APIs; the ecosystem itself isn't agent work.
|
||||
|
||||
## G18 Generative AI features
|
||||
|
||||
- **Missing:** Text to Vector Graphic, Generative Recolor, Generative Shape Fill (#766), Generative Expand,
|
||||
Concept to Vector, Prompt to Edit, Rewrite Text. Adobe's run on its cloud models; ours would be a pluggable
|
||||
provider.
|
||||
- **Needs:** an owner decision on models and providers.
|
||||
- **Estimate:** 30–60 h after that decision.
|
||||
|
||||
## G19 Accessibility
|
||||
|
||||
- **Missing:** AccessKit is on by default, so egui widgets reach screen readers, but custom panels, the canvas and its objects aren't described and nothing has been tested with VoiceOver, Narrator or Orca; keyboard-only canvas operation; high
|
||||
contrast. Illustrator's own accessibility is limited, so this is mostly beyond parity.
|
||||
- **Estimate:** 15–25 h.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | G3: initial Revolve landed (#846), 45–75 h left |
|
||||
| 2026-10-10 | minor | G9 evidence counted from the tracker (28 `.ai`/EPS issues, 20 fixed); impact narrowed to handing files back |
|
||||
| 2026-10-10 | minor | Alpha blockers checked against the core-workflow gate: none |
|
||||
| 2026-10-10 | major | Moved from the ROADMAP's "Where we're lacking"; kept ids G1–G8, added G9–G19 (file formats, stability, bugs, preferences, 30.x additions, pen hardware, performance, localization, ecosystem, AI, accessibility) and re-ranked |
|
||||
@@ -0,0 +1,36 @@
|
||||
# Hardware parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
What hardware Illustrator uses, per platform, against VectorCraft. Illustrator's side is from its public
|
||||
documentation (GPU Performance, touch workspace, pen pressure pages); ours from the code
|
||||
(`crates/ui-egui/src/canvas.rs`, `touch.rs`, `graphics.rs`, `crates/render`). Part of
|
||||
[target-app-parity.md](target-app-parity.md).
|
||||
|
||||
## Summary
|
||||
|
||||
**~35% (estimated), 25–45 h.** VectorCraft rasterizes the canvas on the CPU (multithreaded `vello_cpu`) and uses
|
||||
the GPU only to composite, so any GPU that can show a window works, at the cost of GPU-accelerated preview
|
||||
on huge documents. Pen input is pressure-only and Windows-first.
|
||||
|
||||
| Feature | Illustrator | VectorCraft macOS | Windows | Linux | Web | Status / hours |
|
||||
|---|---|---|---|---|---|---|
|
||||
| GPU-accelerated canvas (GPU Performance, GPU Preview, GPU anti-aliasing) | Metal, DirectX | CPU raster, GPU composite (wgpu/Metal) | CPU raster, GPU composite (DX12, OpenGL fallback) | CPU raster, GPU composite (Vulkan/GL) | WebGPU, WebGL2 fallback | partial; a GPU renderer spike is pending (M5): 15–25 h |
|
||||
| Multithreaded rendering off the UI thread | partial | yes | yes | yes | single-threaded | beyond Illustrator |
|
||||
| Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done |
|
||||
| Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done |
|
||||
| Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | **no** (winit reports none, #852) | yes (pointer force via `WM_POINTER`) | X11 only via XWayland; none on Wayland (#491, #764) | browser pointer events | partial: 4–8 h for macOS and Wayland (winit 0.31) |
|
||||
| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | no | no | no | no | missing: 4–8 h after the input layer (#372) |
|
||||
| Pen eraser end | yes | no | no | no | no | missing |
|
||||
| Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch and scroll | pinch, pan; two-finger tap undo, three-finger redo (#585) | same where winit reports touch | same | partial: touch workspace and rotate gesture missing, 3–5 h |
|
||||
| Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done |
|
||||
| HiDPI / Retina, per-monitor scale | yes | yes | yes | yes | yes | done |
|
||||
| Multiple monitors: panels and document windows on another screen | yes | no (one OS window) | no | no | — | missing: 6–10 h (with multiple windows, gap G7) |
|
||||
| Display colour profile (proof and display through the monitor's ICC) | yes | no | no | no | — | missing: 3–5 h |
|
||||
| 3D GPU rendering (Materials, ray tracing) | yes | — | — | — | — | blocked on 3D (gap G3) |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | First checklist from the canvas, touch and graphics code and the open hardware issues |
|
||||
@@ -0,0 +1,66 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language table, measured from the catalogs) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Per-language status of VectorCraft's interface. How the catalogs work and how to add a language:
|
||||
[`development.md` › Localisation](development.md#localisation). Part of
|
||||
[target-app-parity.md](target-app-parity.md).
|
||||
|
||||
**How it is measured:** rows with a translation in each `crates/ui-egui/src/i18n/<code>.tsv`, counted on
|
||||
origin/main `01e165af`. The reference is the French catalog, which the tests hold complete: 3,416 interface
|
||||
strings (menus, command labels, `tl!` literals: panels, dialogs, tooltips, Preferences) plus 604 `@msg` status and
|
||||
error messages, 4,020 in all. A language's % is its rows over 4,020 (capped at 100% per part). The
|
||||
`complete_menus` flag and `COMPLETE_MESSAGES` list make `i18n::tests` fail if a complete language misses a
|
||||
string, so "full" here is enforced by tests. No language has help content: there is no translated (or English)
|
||||
in-app help, and the docs are English.
|
||||
|
||||
**Native review:** every catalog was written clean-room from the English meaning, most of it by agents
|
||||
(`bflatastic`), with community contributors adding or correcting (German by `dwetscher`, Japanese and Traditional
|
||||
Chinese by `imachus`). None is recorded as reviewed by a native speaker; the catalog headers ask for one.
|
||||
|
||||
## The twelve key languages
|
||||
|
||||
| Language | Code | UI strings translated | Dialogs / tooltips / messages | Script support | Native review | Status | To `full` (h) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| English | `en` | source, 100% | all | Latin | — | full | — |
|
||||
| Simplified Chinese (Mandarin) | `zh-hans` | 3,647 rows; menus, panels, dialogs complete (tests); 85% overall | messages in English (1 of 604) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | partial | 2–3 |
|
||||
| Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) |
|
||||
| Hindi | `hi` | 0 | none | Devanagari: the document's text shapes (HarfRust), untested for Devanagari, but the egui interface does no complex shaping | no | none | 20–30 |
|
||||
| Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 |
|
||||
| French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) |
|
||||
| Portuguese | `pt-br` | 2,726 / 4,020 (68%): every menu label and `tl!` literal | messages in English | Latin | no | partial | 4–6 |
|
||||
| Indonesian | `id` | 0 | none | Latin | no | none | 5–8 |
|
||||
| Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) |
|
||||
| German | `de` | 3,952 / 4,020 (98%; tests hold menus, literals and messages complete) | all (602 of 604 message rows; landed 2026-10-10) | Latin | contributor-written, not reviewed | full | — (review) |
|
||||
| Korean | `ko` | 0 | none | Hangul needs a UI font in craft-fonts; the IME path exists | no | none | 6–10 |
|
||||
| Vietnamese | `vi` | 0 | none | Latin with stacked diacritics (precomposed forms render) | no | none | 5–8 |
|
||||
|
||||
**12-language score: ~54%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 68 + 0 + 100 + 98 + 0 + 0, over 12).
|
||||
**To `full` for all twelve: ~70–110 h**, most of it Arabic (right-to-left interface) and Hindi (interface
|
||||
shaping). Each new Latin-script catalog is ~4,000 rows; the existing ones were written in a few agent hours each
|
||||
plus review. Native-speaker review is human work and isn't in the hours.
|
||||
|
||||
## Other shipped languages
|
||||
|
||||
| Language | Code | Translated | Status |
|
||||
|---|---|---|---|
|
||||
| Traditional Chinese (Taiwan) | `zh-hant` | 3,425 rows, menus and literals complete; messages in English (80% overall) | partial |
|
||||
| Italian | `it` | 4,011 / 4,020 | full |
|
||||
| Russian | `ru` | 4,009 / 4,020 | full |
|
||||
| Ukrainian | `uk` | 4,009 / 4,020 (one/few/many plurals) | full |
|
||||
| Czech | `cs` | 679 / 4,020 (17%): every menu label | menus only |
|
||||
|
||||
Eleven non-English catalogs in all. Illustrator, as its public documentation lists, ships around two dozen
|
||||
interface languages (including Korean, Polish, Dutch, Turkish, the Nordic languages, Hungarian, and Middle East
|
||||
editions with Arabic and Hebrew); it doesn't ship Hindi, Indonesian or Vietnamese.
|
||||
|
||||
## Not done in any language
|
||||
|
||||
Locale-aware number and date formats, a right-to-left interface, translated help, and a Traditional Chinese UI
|
||||
font for the web build.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | First per-language table, measured from the catalog row counts against the complete French catalog |
|
||||
+29
-1
@@ -819,6 +819,34 @@ default) white ones knock out instead; `"discard"` writes none.
|
||||
{"name":"run_command","arguments":{"command":"view.overprintPreview","params":{"on":true}}}
|
||||
```
|
||||
|
||||
## Revolve (initial 3D effect)
|
||||
|
||||
`effect.apply {effect: "threeD.revolve", params: {...}, ids?, item?}` applies a live Revolve to a path
|
||||
or compound path. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and
|
||||
`effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source;
|
||||
SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile.
|
||||
|
||||
Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0),
|
||||
`edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspective`
|
||||
(0..100%, default 0), `segments` (8..128, default 64), `shade` (default true), `lightAzimuth`
|
||||
(degrees, default -45), `lightElevation` (degrees, default 45), `lightIntensity` (0..100%, default 80),
|
||||
`ambient` (0..100%, default 25), and `expandVisibleOnly` (boolean, default true). A vertical-line profile needs a positive offset to have a radius.
|
||||
Open profiles have no end caps. Visibility of intersecting surfaces is approximate.
|
||||
The dialog's Lighting tab manipulates the same `lightAzimuth` and `lightElevation` parameters;
|
||||
its guide visibility and tab choice are UI state, excluded from saved effect parameters.
|
||||
`expandVisibleOnly` is saved with the effect. It trims covered portions of opaque solid surfaces only
|
||||
when running `effect.expandAppearance`, preserving visible interiors through openings. Set it to false
|
||||
to expand all faces. Live preview and SVG/PDF baking keep the full surface. Transparent/unknown paint,
|
||||
non-normal blending, later geometry effects or bounded visibility failures preserve all faces.
|
||||
See [Revolve](revolve.md) for the scope and limitations.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.revolve","item":null,"params":{"angle":360,"offset":20,"edge":"left","rotationX":0,"rotationY":0,"rotationZ":0}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"angle":180,"lightAzimuth":30}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"expandVisibleOnly":true}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{}}}
|
||||
```
|
||||
|
||||
## Edit Colors and Recolor Artwork
|
||||
|
||||
`edit.colors.invert`, `edit.colors.toCMYK`, `edit.colors.toGrayscale`, `edit.colors.toRGB`, `edit.colors.saturate`,
|
||||
@@ -2601,7 +2629,7 @@ without ideographs uses its em box. One undo step.
|
||||
|
||||
## New type in a Japanese interface
|
||||
|
||||
While the interface is in Japanese (VectorCraft › Language, or `auto` on a Japanese system), new type starts with em
|
||||
While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em
|
||||
box top-to-top leading (`leadingModel: "emBoxTop"`) and em box centre character alignment (`charAlign: "emBoxCenter"`);
|
||||
in other languages it starts on the Roman baseline (#432). This covers the Type tools,
|
||||
`text.create` and `text.createInPath`, which also take `leadingModel` and `charAlign` to choose, and `paraStyle.new` /
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Revolve
|
||||
|
||||
This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567).
|
||||
|
||||
Draw an open profile with the Pen tool, select it, then choose **Effect > 3D and Materials > Revolve**.
|
||||
The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right
|
||||
edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical
|
||||
line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface.
|
||||
|
||||
**Revolve angle** sweeps 0 to 360 degrees. **Rotation X/Y/Z** rotates the viewing direction, with all three axes defaulting to 0 degrees. **Perspective**
|
||||
is zero for an orthographic view. **Segments** controls radial resolution (8 to 128 for a full turn).
|
||||
Lighting is a directional light with azimuth, elevation, intensity and ambient contribution.
|
||||
Turn **Shading** off to keep the source paint without lighting.
|
||||
|
||||
The dialog has **Rotation** and **Lighting** tabs. Rotation groups the profile and view controls. Drag a value or its label
|
||||
to adjust it, use its slider, or double-click the value to type. Angles show degrees and lighting
|
||||
values show percentages. **Segments** is in the **Advanced** section. Lighting controls are disabled
|
||||
when **Shading** is off. A filled dot at the end of a row means the value differs from its default;
|
||||
click the dot to reset that value. **Reset all** restores every Revolve parameter to its default.
|
||||
|
||||
The compact settings dialog opens beside the artboard. With **Preview** enabled, the selected
|
||||
object has X (red), Y (green) and Z (blue) rotation rings directly on the artboard. Drag a ring to
|
||||
adjust that axis; drag inside the rings to rotate freely. Hold Shift to snap to 15-degree increments.
|
||||
The rings and the rotation fields stay in sync. Escape during a drag restores that gesture and
|
||||
keeps the settings open. Escape again or Cancel restores the document before the dialog opened.
|
||||
The gizmo appears while editing Revolve; normal source-path selection returns when the dialog closes.
|
||||
|
||||
Select the **Lighting** tab to use the light gizmo on the artboard. Drag the sun handle to move
|
||||
its direction on the current hemisphere. A hollow sun marks a back light. Drag the **Direction**
|
||||
ring or its label horizontally to adjust azimuth; drag the **Elevation** arc or its label vertically
|
||||
to adjust elevation. The labels remain usable when a ring is viewed edge-on or the light is at a pole.
|
||||
Hold Shift to snap to 15-degree increments. Light gestures update their matching fields without
|
||||
rotating the object, and keep the same temporary preview, Escape, Cancel and undo behaviour.
|
||||
Light intensity and ambient contribution remain available as draggable fields and sliders.
|
||||
|
||||
**Shading** off hides the light gizmo and disables lighting controls. Switch to **Rotation** to return
|
||||
to the object rings. **Show axis** in **Advanced** displays the source's vertical axis on the object.
|
||||
Turning **Preview** off restores the original artwork and hides artboard controls until Preview is enabled.
|
||||
In small windows, the controls scroll while OK and Cancel stay visible. Reset dots restore individual
|
||||
settings; **Reset all** restores parameters on both tabs.
|
||||
|
||||
Preview changes are temporary until OK. Cancel restores the document; OK creates one undo step.
|
||||
The source anchors remain editable. Appearance lists the effect for reopening its options.
|
||||
SVG/PDF export evaluates filled vector faces; native saves retain the profile and effect parameters.
|
||||
**Object > Expand Appearance** replaces the live effect with ordinary editable vector faces.
|
||||
|
||||
In **Advanced**, **Keep visible surfaces only when expanding** is on by default. Expansion removes
|
||||
completely covered faces and trims partially covered ones using the current rotation and perspective.
|
||||
Visible interior surfaces through open ends remain. Uncheck it to retain every projected face.
|
||||
The checkbox has its own reset dot and is included in **Reset all**. Its saved command parameter is
|
||||
`expandVisibleOnly`; it does not change the live preview or SVG/PDF export baking.
|
||||
|
||||
Trimming applies to opaque solid paint. Transparent objects, gradients, patterns, non-normal blending,
|
||||
later geometry effects and surfaces exceeding bounded visibility limits keep their complete geometry
|
||||
to preserve their appearance. Visibility follows the same painter order as the live effect, including
|
||||
its approximation for intersecting surfaces. Expansion changes are undoable with the standard command.
|
||||
|
||||
## Initial limits
|
||||
|
||||
- Paths and compound paths only. Type, images and groups must be converted to paths first.
|
||||
- Open profiles produce uncapped surfaces. Closed profile boundaries are swept as provided.
|
||||
- One Revolve per source object. Edit its options rather than applying another.
|
||||
- Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint.
|
||||
- Flat shading, one directional light, no textures, cast shadows, end caps or physical materials.
|
||||
- Faces use back-to-front depth sorting. Intersecting profiles can have incorrect visibility.
|
||||
- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces.
|
||||
- Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile.
|
||||
- Extrude & Bevel, Inflate, Rotate and Materials remain unimplemented.
|
||||
|
||||
## Architecture
|
||||
|
||||
`vectorcraft-three-d` depends only on `vectorcraft-geom`. It creates a surface of revolution,
|
||||
applies Euler view rotation, projects it, shades faces and sorts their depth. It has no UI or GPU dependency.
|
||||
`vectorcraft-effects` turns those faces into document art. The renderer, export baking and
|
||||
Expand Appearance use the same evaluator. The standard `effect.apply`, `effect.setParams`,
|
||||
`effect.remove` and `effect.expandAppearance` commands expose it to the CLI, control channel and MCP.
|
||||
|
||||
Behaviour reference: [public 3D effects documentation](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html).
|
||||
This implementation uses original mathematics and source code, with no Adobe software, assets or presets.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (added the Alpha gate table; earlier the same day: moved the milestone table and direction here from ROADMAP.md, added Current focus) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
|
||||
agent wall-clock hours, one agent. There is no `cargo xtask scorecard` or `cargo xtask parity` in this repo yet
|
||||
(the clean-room rule rules out a menu dump of Illustrator as an oracle; a checklist built from Illustrator's
|
||||
public menu documentation would be allowed).
|
||||
|
||||
## Alpha gate
|
||||
|
||||
The core-workflow gate from craftrules' progress-docs standard: what a typical professional does with
|
||||
Illustrator every day, checked end to end on the main platform (macOS) with save and reopen in the native
|
||||
`.vectorcraft` format (lossless, property-tested round trips in `crates/format/tests/prop_format.rs`).
|
||||
Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so VectorCraft passes and stays alpha.
|
||||
|
||||
| Core workflow | Works end to end? | Evidence | Hours to pass |
|
||||
|---|---|---|---|
|
||||
| Draw and edit paths precisely: Pen, Curvature, Direct Selection on anchors and handles, smart guides and snapping, Pathfinder | yes | `crates/tools/src/pen.rs`, `direct.rs`, `guides.rs` and their tests; 10 exact Pathfinder booleans; [ui-parity.md](ui-parity.md). Shape Builder misses regions in some files (#937, #893), Pathfinder covers the same results | 0 |
|
||||
| Paint and style: fills and strokes, global and spot swatches, gradients, Appearance, transparency, graphic styles | yes | M3 done (92 tasks); area scores 92–97% in [target-app-parity.md](target-app-parity.md#feature-areas) | 0 |
|
||||
| Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); hyphenation and justification options missing (not blocking) | 0 |
|
||||
| Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; ghost borders in some Asset Export output (#983) | 0 |
|
||||
| Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 |
|
||||
| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951, #868) and lose guides (#779); Save As `.ai` is PDF-compatible, which Illustrator opens without its own editing data. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) |
|
||||
|
||||
## Current focus
|
||||
|
||||
1. **Interaction-fidelity pass** (G1) by tool, from [ui-parity.md](ui-parity.md): Pen and Curvature, Direct
|
||||
Selection, smart guides and snapping first. 60–90 h, parallel by tool.
|
||||
2. **`.ai` files from real users** (G9): layer structure (#951, #868), guides (#779). 10–20 h.
|
||||
3. **Launch and stability** (G10): the Windows and Linux start-up reports. 20–35 h.
|
||||
4. **Core-tool bugs** (G11): Shape Builder regions (#937, #893), Asset Export borders (#983). 8–15 h.
|
||||
5. **Raster effects** (G2) continue alongside: Artistic, Sketch, then the Effect Gallery. 24–40 h.
|
||||
|
||||
## Next
|
||||
|
||||
- **Then:** advanced type (G4) and the Illustrator 2026 additions (G13), then 3D (G3), each with a plan in
|
||||
`plan/` before coding.
|
||||
- **Alongside:** close the 28 stubbed menu items; run `vectorcraft-cli perf` on an idle machine and fix any
|
||||
budget missed (G15).
|
||||
- **Beta:** G1, G9, G10, G11, G12, the corpus part of G8, type core, macOS pen pressure and the perf budgets
|
||||
(~170–270 h one agent, ~50–80 h with 4–6 agents).
|
||||
- **1.0:** feature parity plus the fidelity pass, hardening and packaging for all three desktop OSes and the web.
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Milestone | Status | Est. remaining, one agent (h) |
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; graph Design/Column/Marker designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| | **Total to full parity** (one agent; from [target-app-parity.md](target-app-parity.md)) | | **~360–590** |
|
||||
|
||||
Milestone estimates predate the 2026-10-10 re-measure for M1–M14; the area table in
|
||||
[target-app-parity.md](target-app-parity.md) is authoritative when they disagree.
|
||||
|
||||
## Progress log before the progress-docs standard
|
||||
|
||||
The ROADMAP's "last updated" line as of 2026-10-10, kept as the record of what landed up to then:
|
||||
|
||||
After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groLine truncated
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | M9 row from main: initial Revolve |
|
||||
| 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha |
|
||||
| 2026-10-10 | major | Created from ROADMAP.md's milestone table and "Where we're going"; added Current focus and the beta list |
|
||||
@@ -0,0 +1,462 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
|
||||
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
|
||||
[file formats](file-format-parity.md), [hardware](hardware-parity.md), [languages](localization-parity.md),
|
||||
[effects](effects-parity.md) and [type](type-parity.md).
|
||||
|
||||
## Headline
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (does each Illustrator menu item, tool, effect and format exist?) | **~88%** | measured in part: menu tree 348 of 375 items wired (92.8%), tools 89 of 92 (97%), vector effects 44 of 50 (88%), Photoshop-style effects 28 of 57 (49%), 3D 1 of 4 (initial Revolve); blended by the area weights below |
|
||||
| **Feature depth** (each area scored by behaviour, weighted) | **~75%** | estimated, area table below |
|
||||
| **Ready for real work, mainstream illustrator** | **~65%** (60–70%) | estimated, see [Ready for real work](#ready-for-real-work) |
|
||||
| **Ready for real work, full Illustrator** (decides the stage) | **~62%** (57–67%) | estimated: additive weighted sum over the dimensions, see [Ready for real work](#ready-for-real-work) |
|
||||
| **Ready for real work, essentials user** (core features only, see [Essentials user](#essentials-user)) | **~75%** (70–80%) | estimated |
|
||||
| **Remaining to beta** | **~170–270 h** one agent | estimated, [gaps.md](gaps.md) beta list |
|
||||
| **Remaining to full parity** | **~360–590 h** one agent · ~95–165 h wall clock with 4–6 agents | estimated, sum of the area table + fidelity pass + hardening |
|
||||
|
||||
All hours are **Opus 5.5 agent wall-clock hours**: one agent session working sequentially, including builds,
|
||||
tests and the CI gate. See [Calibration](#calibration).
|
||||
|
||||
## How this was measured (2026-10-10)
|
||||
|
||||
- **Target version.** Illustrator 2026, the 30.x line. Feature set from Adobe's public documentation and release
|
||||
announcements: 30.0 (October 2025: font browser, gradient dither and perceptual blending, tangent and
|
||||
perpendicular snapping, artboard locking and colours, Turntable beta, Firefly integration), 30.2 (TIFF in
|
||||
Export for Screens), 30.3, 30.5 (Concept to Vector, Prompt to Edit, Rewrite Text) and 30.7 (Blend panel with
|
||||
ease in/out). Sources: the [30.0 announcement](https://community.adobe.com/questions-652/illustrator-2026-v30-0-font-browser-color-snapping-artboards-enhancements-turntable-and-more-1546612),
|
||||
the [30.7 announcement](https://community.adobe.com/announcements-651/illustrator-30-7-new-blend-panel-ease-in-out-blends-generate-artwork-with-firefly-bug-fixes-and-more-1635245)
|
||||
and helpx feature pages.
|
||||
- **The installed Illustrator was not inspected.** Illustrator 2026 is installed on the owner's Mac, but this
|
||||
repo's clean-room rule ([`AGENTS.md`](../AGENTS.md), from the project owner) forbids reading anything inside an
|
||||
Adobe install, its `Info.plist` and `.lproj` folders included, and forbids launching or measuring it. So
|
||||
nothing here is a menu dump or a side-by-side measurement: the numbers come from our own code against
|
||||
Illustrator's public documentation.
|
||||
- **From the code (measured, origin/main `01e165af`):**
|
||||
- Menu tree (`crates/ui-egui/src/menus.rs`, laid out in Illustrator's menu order): 347 items bound to
|
||||
commands, 27 stubs (`todo(…)`/`todos(…)`), so 92.8% of menu items do something (348 and 27 after the
|
||||
Revolve merge). The stubs: Effect › 3D and Materials (4), Effect › SVG Filters, Edit › Spelling (3), Graph › Design/Column/Marker (3), Live Paint
|
||||
Gap Options, Optical Margin Alignment, Start Global Edit, global and video rulers (2), Snap to Glyph,
|
||||
Window › Arrange (4), SVG Interactivity, the library placeholders (3), Other Script…, Help.
|
||||
- Engine commands: ~710 ids registered with `cmd!` across `crates/engine/src/cmd` (plus macro-generated
|
||||
families), and 85 UI-only commands (`UI_COMMANDS`).
|
||||
- Tools: 91 in `crates/tools/src/catalog.rs`; Touch Type is listed but not implemented, and the Dimension
|
||||
tool (added in Illustrator 28.x) is absent.
|
||||
- Effects catalogue (`crates/effects/src/lib.rs`): see [effects-parity.md](effects-parity.md).
|
||||
- Tests: 4,924 `#[test]` functions plus 40 property-test blocks (was "~2,840" in the last estimate).
|
||||
~354k lines of Rust (was ~221k on 2026-10-07).
|
||||
- Formats: the `FORMATS` table in `crates/engine/src/cmd/fileio/mod.rs`, see
|
||||
[file-format-parity.md](file-format-parity.md).
|
||||
- Languages: row counts of `crates/ui-egui/src/i18n/*.tsv`, see [localization-parity.md](localization-parity.md).
|
||||
- **From users:** the 71 open GitHub issues on 2026-10-10, which show what real users hit first (Illustrator
|
||||
`.ai` files opening with the wrong layer structure #951/#868, guides lost from `.ai` #779, Shape Builder
|
||||
region detection #937/#893, launch failures on some Windows and Linux machines #858/#964/#620, pen pressure on
|
||||
macOS #852, Preferences options that do nothing #394).
|
||||
- **Presence is not parity.** Each area is scored by behaviour against the public documentation, and an area is
|
||||
100% only when every feature in it behaves like Illustrator. Scores were set by the agents that built the
|
||||
features, so they are upper bounds; this pass lowered the ones contradicted by open issues.
|
||||
|
||||
## Feature areas
|
||||
|
||||
Weights are the share of Illustrator users' everyday work each area represents (drawing, paths, paint and type
|
||||
dominate; 3D, graphs and automation are specialist). They sum to 105. Feature depth = Σ weight × score / Σ weight.
|
||||
|
||||
| Area | Weight | Done | Main gaps | One agent (h) | Kind |
|
||||
|---|---:|---:|---|---:|---|
|
||||
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances | 4–6 | estimated |
|
||||
| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool, Live Corners on curved corners; see [ui-parity.md](ui-parity.md) | 8–13 | estimated |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937, #893), its gap detection and options; Live Paint Gap Options | 4–8 | estimated (lowered from 86%: open issues) |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither, perceptual blending and gradient presets; freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated (lowered from 96%: target moved) |
|
||||
| Strokes, brushes, width profiles | 5 | 88% | Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated |
|
||||
| Raster effects (Effect Gallery) | 4 | 48% | 29 Photoshop-style filters (Artistic, Sketch), the Effect Gallery dialog, Load Texture | 24–40 | measured count 28/57 |
|
||||
| 3D and Materials (and Turntable) | 4 | ~8% | initial live Revolve done (flat shading, one light, no caps, approximate visibility for intersecting surfaces); Extrude & Bevel, Inflate, Rotate, materials, Turntable views, 3D export | 50–85 | estimated (Revolve landed after the first measure, #846) |
|
||||
| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, see [type-parity.md](type-parity.md) | 14–19 | estimated |
|
||||
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi, proportional vertical metrics), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 85% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape | 6–10 | estimated (lowered from 88%: target moved) |
|
||||
| Image Trace, graphs, image tools | 3 | 75% | graph Design/Column/Marker, Image Trace Create Strokes depth (Create Object Mosaic is done) | 5–8 | estimated (raised from 72%) |
|
||||
| Layers, artboards, document setup | 5 | 78% | 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export | 5–8 | estimated (lowered from 82%: target moved) |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange, Snap to Glyph | 9–14 | estimated |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides, global/video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved) |
|
||||
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951, #868), guides lost (#779), no writing of Illustrator's editing data, FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md) | 10–20 | estimated (lowered from 85%: open issues) |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983), TIFF in Export for Screens (30.2) | 2–4 | estimated |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated |
|
||||
| Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated |
|
||||
| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, ~17 Preferences options stored but unused (#394), panels outside the window, SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated |
|
||||
| Libraries, Links, Package | 2 | 82% | shared libraries, linked graphics, gradients and patterns as library items (no cloud by design) | 2–3 | estimated |
|
||||
| Generative AI (Text to Vector, Generative Recolor, Shape Fill, Concept to Vector, Prompt to Edit, Rewrite Text) | 2 | 0% | a pluggable provider and the panels; needs an owner decision on models | 30–60 | measured (absent) |
|
||||
| **Feature depth** | **105** | **~75%** | | **~250–420** | |
|
||||
| Interaction-fidelity pass (every tool's modifiers, cursors, Properties per context) | | | [ui-parity.md](ui-parity.md) | 60–90 | estimated |
|
||||
| Hardening (real-file corpus, cross-platform and browser QA, launch failures, performance budgets) | | | [gaps.md](gaps.md) | 50–80 | estimated |
|
||||
| **Full parity** | | **~55%** | | **~360–590** | |
|
||||
|
||||
Excluding the generative-AI row (the old table had none), depth is ~76% and the remaining feature work
|
||||
~220–360 h. With 4–6 agents on disjoint crates, wall-clock time divides by about 3.5–4 (shared files such as
|
||||
`menus.rs` and the i18n catalogs serialize some work): ~95–165 h to full parity.
|
||||
|
||||
**Why the totals moved up from ~320–500 h (2026-10-05):** the target moved (30.x added gradient dither,
|
||||
perceptual blending, new snaps, artboard locking, the Blend panel, Turntable), open issues lowered three areas,
|
||||
the raster-effects row was re-counted bottom-up from measured filter throughput, and generative AI is now
|
||||
counted. Work that landed since (Variables, Distort/Pixelate/Texture filters, Object Mosaic, German, placed
|
||||
documents) moved other rows up.
|
||||
|
||||
## Ready for real work
|
||||
|
||||
Three audiences, each with its own readiness and its own hours to ~95%. Essentials ⊂ mainstream ⊂ full, so the
|
||||
hours nest. Hours are calibrated as in [Calibration](#calibration). They divide by about 3.5–4 across 4–6 agents
|
||||
for the feature and fidelity work; stability on users' hardware, the real-file corpus and native-language
|
||||
review parallelize less and need humans.
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|
||||
|---|---:|---:|---:|---|
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (24–40 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem |
|
||||
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
|
||||
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
|
||||
|
||||
### Full Illustrator: additive weighted sum over the dimensions
|
||||
|
||||
The standard's method, the same in every app: a weighted **sum** over the dimensions, with written weights.
|
||||
Values are from [By dimension](#by-dimension).
|
||||
|
||||
| Dimension | Weight | Value | Contribution |
|
||||
|---|---:|---:|---:|
|
||||
| Features (depth) | 35 | 75% | 26.3 |
|
||||
| UI/UX fidelity | 15 | 55% | 8.3 |
|
||||
| File formats | 12 | 75% | 9.0 |
|
||||
| Stability | 10 | 60% | 6.0 |
|
||||
| Performance | 8 | 60% | 4.8 |
|
||||
| Platforms | 5 | 80% | 4.0 |
|
||||
| Hardware | 4 | 35% | 1.4 |
|
||||
| Localization | 4 | 54% | 2.2 |
|
||||
| Ecosystem and plug-ins | 4 | 15% | 0.6 |
|
||||
| AI features | 3 | 0% | 0.0 |
|
||||
| **Ready for real work** | **100** | | **~62%** |
|
||||
|
||||
The weights put most of the number on what professionals use daily (features, interaction, files, stability).
|
||||
Generative AI also sits inside the features row at weight 2 of 105, so it is counted slightly twice; that costs
|
||||
under a point.
|
||||
|
||||
### Mainstream illustrator and essentials user
|
||||
|
||||
Both start from feature depth over the areas concerned. **Mainstream:** ~83% over 16 areas (weight 83 of 105:
|
||||
selection, drawing, path operations, colour, strokes, appearance, live effects, type core, symbols and blends,
|
||||
layers and artboards, view, guides, file formats, Export for Screens, UI chrome, print). That is discounted
|
||||
×0.9 for interaction fidelity, ×0.92 for stability on real machines and ×0.92 for Illustrator-file exchange
|
||||
(83 × 0.9 × 0.92 × 0.92 ≈ 63). The dimensions a typical illustrator rarely hits (plug-in ecosystem,
|
||||
generative AI, pen tilt, languages beyond their own) are left out. **Essentials:** see [Essentials user](#essentials-user).
|
||||
|
||||
The discounts, with the evidence:
|
||||
|
||||
- **Illustrator's own files (×0.92).** `.ai` and EPS open as editable art by reading their PDF/PostScript and the
|
||||
structure of the editing data. Users do open legacy files a lot: 28 of the 350 issues (8%, ~22 people) are
|
||||
about `.ai`/EPS, the most common single format theme. 20 of the 28 are fixed (often the same day). The 8 open
|
||||
ones are about structure, not missing art: layers flattened or multiplied (#951: 109 layers instead of 14;
|
||||
#868), guides lost (#779), slower opening (#758), live effects, brushes and symbols as their drawn look
|
||||
(#637). VectorCraft can't write Illustrator's editing data (undocumented), so its `.ai` opens in Illustrator
|
||||
as plain art. That blocks beta (the main-format rule) for people who hand files back to Illustrator users,
|
||||
but it doesn't stop someone opening and reworking their own archive.
|
||||
- **Interaction fidelity (×0.9).** Pen, Direct Selection, smart guides and snapping are deep
|
||||
([ui-parity.md](ui-parity.md)), and there has never been a side-by-side session. The issue tracker says the
|
||||
details mostly hold: 79 issues are about core drawing (pen, paths, selection, snapping, transform), 68 of them
|
||||
(86%) closed, 11 open (#991, #975, #955, #973 among them). One practitioner wrote "0 learning curve from
|
||||
pretty heavy AI use over last 20+ years" (#541).
|
||||
- **Stability on real machines (×0.92).** The code never panics by design, and the main platform (macOS) starts
|
||||
reliably, but 26 issues mention a crash, freeze or failed launch (8 open: #858, #964 fixed the day of this
|
||||
review, #620, #818, #905 on Windows 7, #834). There is no field record and no real-world file corpus yet.
|
||||
- **Performance unverified.** Budgets (`vectorcraft-cli perf`) haven't been run on an idle machine since
|
||||
2026-10-01; the last loaded run missed the 16 ms frame budget at 50k paths. Not discounted separately: no
|
||||
user has reported slowness in mainstream work beyond #575 (Linux AppImage) and #758 (`.ai` open time).
|
||||
- **Missing subsystems** a minority need daily (3D, the Effect Gallery, CJK vertical composition, scripting)
|
||||
pull the full number below the mainstream one.
|
||||
|
||||
### Essentials user
|
||||
|
||||
A beginner or casual user who touches only the core features, at their default settings. Excluded: advanced
|
||||
options, pro workflows, file-exchange edge cases, and everything the mainstream number already leaves out.
|
||||
Depth here is "does the basic use work", not the area's full score.
|
||||
|
||||
| Core feature | Weight | Depth | Evidence |
|
||||
|---|---:|---:|---|
|
||||
| New, open and save documents | 8 | 95% | New Document presets, Open for SVG/PDF/`.ai`/images, lossless native save, Data Recovery |
|
||||
| Shape tools (rectangle, ellipse, polygon, star, line) | 9 | 95% | all shape tools, Live Corners |
|
||||
| Selection, move, scale, rotate | 9 | 92% | bounding box, smart guides, transform tools |
|
||||
| Pen and Pencil, basic use | 7 | 88% | [ui-parity.md](ui-parity.md); a few Pencil and Curvature options missing |
|
||||
| Fill, stroke, colour, swatches | 9 | 95% | M3 done |
|
||||
| Gradients, basic | 4 | 90% | Gradient panel and on-canvas annotator |
|
||||
| Type: point and area text, font, size | 8 | 88% | font menu with previews; hyphenation options missing (rarely touched) |
|
||||
| Align, group, arrange | 6 | 95% | |
|
||||
| Layers, basic | 4 | 85% | Layers panel rework; no row context menus |
|
||||
| Shape Builder | 5 | 70% | beginners lean on it; region detection fails in some files (#937, #893) |
|
||||
| Pathfinder | 4 | 95% | exact booleans |
|
||||
| Place images, Image Trace | 4 | 85% | 13 trace presets |
|
||||
| Export PNG/JPG/SVG/PDF, print | 8 | 88% | ghost borders in some Asset Export output (#983) |
|
||||
| Undo and redo | 4 | 100% | unlimited, property-tested |
|
||||
| Zoom, pan, fit | 4 | 95% | |
|
||||
| Artboards, basic | 4 | 88% | |
|
||||
| **Weighted depth** | **97** | **~91%** | |
|
||||
|
||||
The discounts a beginner actually hits:
|
||||
|
||||
- **Launch and stability, ×0.93:** fine on macOS, but some Windows machines fail to start (#858, #620, #818).
|
||||
- **Discoverability and UI clarity, ×0.92:** the layout follows Illustrator's and comes in 11 languages, but
|
||||
there is no in-app help (Help › VectorCraft Help… is a stub), no Learn panel or tutorials, and a newcomer
|
||||
meets Illustrator's density without Illustrator's learning material.
|
||||
- **Opening files people send them, ×0.96:** PDF, SVG, images and most `.ai` files open as editable art.
|
||||
Layer-structure problems (#951) matter less to a beginner.
|
||||
|
||||
91 × 0.93 × 0.92 × 0.96 ≈ 75: **~75% (70–80%), estimated.**
|
||||
|
||||
### User sentiment (measured 2026-10-10)
|
||||
|
||||
All 350 issues and 964 issue comments on GitHub (Discussions are off), authors other than the maintainers:
|
||||
|
||||
- **Praise:** 17 issue threads from 15 people ("Great work on this. It's kind of amazing" #954; "really great
|
||||
job" #841; "I really love this project" #620; "0 learning curve from pretty heavy AI use over last 20+ years"
|
||||
#541).
|
||||
- **"Replacing Illustrator" reports:** none found in the tracker. Praise is for the app as it is, not reports
|
||||
of having switched on paid work. Owner reports from practitioners outside GitHub aren't counted here.
|
||||
- **Themes of the 350 issues:** core drawing 79 (11 open), import/export 59 (17 open), launch/crash/platform/
|
||||
performance 51 (12 open), UI chrome and layers 46 (10 open), type 28 (4 open), colour 12 (5 open), other and
|
||||
requests 75 (24 open). 35 are titled as feature requests. The open set is mostly core-path bugs and
|
||||
file-exchange problems, not niche features.
|
||||
|
||||
**Stage: alpha.** All six core workflows pass the core-workflow gate end to end on macOS with save and reopen
|
||||
([roadmap.md › Alpha gate](roadmap.md#alpha-gate)); file exchange with Illustrator users is partial, which is
|
||||
what keeps it from beta, not from alpha.
|
||||
|
||||
## By dimension
|
||||
|
||||
| Dimension | % | One agent (h) | Kind | Doc |
|
||||
|---|---:|---:|---|---|
|
||||
| Features (depth, weighted) | 75% | 220–360 | estimated | this file |
|
||||
| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md) |
|
||||
| File formats | 75% | 15–30 | estimated | [file-format-parity.md](file-format-parity.md) |
|
||||
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | estimated | [hardware-parity.md](hardware-parity.md) |
|
||||
| Localization (the 12 key languages, equally weighted) | 54% | 70–110 | measured from catalogs | [localization-parity.md](localization-parity.md) |
|
||||
| Performance | ~60% | 15–25 | estimated (unverified budgets) | [gaps.md](gaps.md) |
|
||||
| Stability | ~60% | 30–50 | estimated | [gaps.md](gaps.md) |
|
||||
| Platforms (macOS, Windows x64/arm64, Linux, FreeBSD, web) | 80% | 15–25 | estimated | [gaps.md](gaps.md) |
|
||||
| Ecosystem and plug-ins (scripting, third-party plug-ins) | 15% | 30–60 | estimated | [gaps.md](gaps.md) |
|
||||
| AI features (generative) | 0% | 30–60 (+ owner decision) | measured (absent) | [gaps.md](gaps.md) |
|
||||
| Agent automation (MCP, CLI, control channel) | beyond Illustrator | — | measured | [mcp.md](mcp.md) |
|
||||
|
||||
The dimension hours overlap (the feature rows already hold some UI, format and AI work), so they don't add up
|
||||
to the full-parity total.
|
||||
|
||||
## Calibration
|
||||
|
||||
Hours come from this repo's own history (git log and merged-PR timestamps, `gh pr list`):
|
||||
|
||||
- **Raster filters**, one agent working serially on [gap 2](gaps.md): Pixelate (4 filters, merged 2026-10-10
|
||||
08:47), Texture (6, 12:44), Distort (3, 16:24). About 4 h per batch, **0.7–1.2 h per Photoshop-style
|
||||
filter** including dialog, catalogue row, menus and translations into 11 languages.
|
||||
- **M3 (paint and appearance):** a 92-task plan whose packages were planned at 5–11 h each took about
|
||||
1–1.5 agent hours each, on 2026-10-04/05 with 6–8 agents.
|
||||
- **Medium features:** about 0.8 h each (Text Wrap, Graphs, Effect › Pathfinder…: ~20 in ~16 h on 2026-10-02).
|
||||
- **Whole repo:** 1,671 commits and 592 merged PRs from 2026-09-30 to 2026-10-10, with several agents in
|
||||
parallel and a dozen community contributors.
|
||||
|
||||
Large subsystems (3D, CJK composition, generative AI) have no comparable history and are counted bottom-up,
|
||||
with a wider range. What needs a human: native-speaker review of the translations, an owner decision on
|
||||
generative models, real Illustrator files that users own (used locally, never committed), and Wacom/pen
|
||||
hardware for tilt and rotation.
|
||||
|
||||
## Where we already beat Illustrator
|
||||
|
||||
Exact curve booleans, off-thread multithreaded rendering, unlimited undo, lossless SVG/PDF export of live effects,
|
||||
a documented JSON format, Affinity 1–3 import, the same app on the web and on Linux/FreeBSD, scrubby numeric fields,
|
||||
sandboxed WebAssembly plug-ins, and every command, gesture and dialog drivable by agents (MCP, CLI, control channel).
|
||||
|
||||
## Out of scope (by design or by law)
|
||||
|
||||
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Projects, Express templates, Firefly): pluggable
|
||||
provider APIs at most, never built in.
|
||||
- **Adobe's bundled assets** (swatch, brush, symbol libraries, presets, icons): ours are original.
|
||||
- **Illustrator's private editing data, beyond its structure:** see
|
||||
[file-format-parity.md](file-format-parity.md#illustrators-private-editing-data).
|
||||
|
||||
## Detailed missing items per area (2026-10-05 table, still current unless the area table above says otherwise)
|
||||
|
||||
The previous estimate's per-area missing lists, kept as evidence. Scores in it are superseded by the table above.
|
||||
|
||||
_Method (2026-10-02; M3 rows re-derived 2026-10-04, file, export, print, links and UI-chrome rows after M4 on 2026-10-05):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of
|
||||
real users' work) each represents. Each area is scored by depth of behaviour, not by presence of a menu item: an area
|
||||
is 100% only when every feature in it behaves like Illustrator. Feature parity = Σ weight × score / Σ weight. Remaining
|
||||
time is counted per area from what is missing, calibrated on measured throughput: in the last session one agent landed
|
||||
about 20 medium features (Text Wrap, Graphs, Effect → Pathfinder, export baking…) in ~16 h wall clock including builds
|
||||
and CI on a heavily loaded machine — about 0.8 h per medium feature; large subsystems (3D, raster filters, vertical type)
|
||||
are counted bottom-up. The M3 rows (colour, strokes, appearance) come from a detailed 92-task M3 plan whose review
|
||||
found more missing depth than the first estimate; all 92 tasks landed on 2026-10-04 and 2026-10-05 with 6–8 agents in parallel; its packages, planned at 5–11 h each, took
|
||||
about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) are likely high.
|
||||
|
||||
| Area | Weight | Done | Missing (main items) | One agent (h) |
|
||||
|---|---:|---:|---|---:|
|
||||
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
|
||||
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
|
||||
| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 |
|
||||
| 3D and Materials | 4 | ~8% | Initial Revolve only, with simple lighting and Revolve-specific visible-surface expansion; Extrude & Bevel, Inflate, Rotate, caps, materials, shadows and more complete visibility remain | 45–75 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4 |
|
||||
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 65% | scripting surface, batch (Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 13–20 |
|
||||
| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
|
||||
| **Feature parity** | **103** | **~75%** | | **~210–330** |
|
||||
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
|
||||
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
|
||||
| **Full parity** | | **~56%** | | **~320–500** |
|
||||
|
||||
_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except Touch Type; 51 panel modules; Illustrator-style live
|
||||
effects ~44/54, Photoshop-style raster effects ~4/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
|
||||
## Honest assessment by dimension (2026-10-05)
|
||||
|
||||
The previous assessment's evidence per dimension, kept as it was; the [By dimension](#by-dimension) table above supersedes its numbers.
|
||||
|
||||
**In one line:** about two-thirds to three-quarters of Illustrator's features exist and work, and for everyday
|
||||
vector illustration (drawing, paths, paint and appearance, SVG/PDF/print) VectorCraft is close to usable. Measured
|
||||
against "a power user can't tell the difference", it is closer to 40–55%. What stands between here and "anyone can
|
||||
switch" is a handful of large subsystems plus a fidelity pass, not a long tail of small fixes.
|
||||
|
||||
**How much to trust the numbers:** area scores are graded by the agents that built the features, from behaviour docs
|
||||
(`plan/illustrator/`) and tests, not by side-by-side use of Illustrator. Treat them as upper bounds. Look & feel is
|
||||
the only dimension measured against the reference app. Performance budgets haven't been re-run on an idle machine
|
||||
since 2026-10-01.
|
||||
|
||||
|
||||
| Dimension | Estimate | Evidence and what's missing |
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (~8%, initial Revolve only), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) |
|
||||
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
|
||||
|
||||
## Evidence: what ships today, by area
|
||||
|
||||
Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands.
|
||||
|
||||
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
|
||||
- **Automation:**
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- Variables (data merge): text and visibility variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility Dynamic, Capture/Update Data Set, and a row in italics when an edit leaves the art not matching it; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832).
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it).
|
||||
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
|
||||
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
|
||||
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, German, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
|
||||
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
|
||||
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). About 17 stored options still do nothing yet (the rest of #394: Highlight Alternate Glyphs, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
|
||||
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
|
||||
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
|
||||
- The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box.
|
||||
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
|
||||
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected).
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
- **Tools:**
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784).
|
||||
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
|
||||
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come). A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph Design, Column and Marker artwork remain open.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel.
|
||||
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them).
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
|
||||
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)).
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and Line truncated
|
||||
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
|
||||
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
|
||||
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`).
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md).
|
||||
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
|
||||
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
|
||||
- Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`).
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Merged main: initial live Revolve landed (#846). 3D 0% → ~8% (50–85 h), menu stubs 28 → 27; depth, readiness and totals unchanged within rounding |
|
||||
| 2026-10-10 | minor | Method aligned with the standard, no new evidence: the full ready-for-real-work number is the additive weighted sum over the dimensions (~62%). The multiplicative 55% from earlier today is withdrawn, and the 50% before it was an unbroken-down judgement. Added hours to ~95% for each audience (full 340–560 h, mainstream 230–380 h, essentials 60–105 h). Beta distance is now ~13 points |
|
||||
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%): 16 core features weighted, depth ~91%, discounted for launch, discoverability and files people send |
|
||||
| 2026-10-10 | minor | Second look at ready for real work at the owner's request: 50% → 55% full Illustrator, and a new mainstream-illustrator figure of 65%. Why: the old 25-point discount was a judgement with no breakdown. Now each discount is written down and checked against the tracker: 86% of 79 core-drawing issues closed, 20 of 28 `.ai`/EPS issues fixed and the open ones about structure, praise from 15 people, no reports of switching; dimensions a typical illustrator rarely hits are taken out of readiness. Feature breadth, depth and hours unchanged |
|
||||
| 2026-10-10 | minor | Stage checked against the core-workflow gate: passes, stays alpha |
|
||||
| 2026-10-10 | major | Full re-measure against Illustrator 2026 30.x from code counts, open issues and public docs; added breadth/depth/ready-for-real-work numbers, generative-AI row, calibration from PR timestamps; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file |
|
||||
| 2026-10-05 | major | (in ROADMAP.md) M3 and M4 rows re-derived after the paint and files milestones |
|
||||
| 2026-10-02 | major | (in ROADMAP.md) First 22-area weighted estimate |
|
||||
@@ -0,0 +1,51 @@
|
||||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, split out of the parity table's two type rows) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
|
||||
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
|
||||
code and tests (`crates/text`, `crates/engine/src/cmd/type*.rs`, `textstyles.rs`, `crates/tools/src/text.rs`)
|
||||
against Illustrator's public documentation; **estimated** unless noted.
|
||||
|
||||
## Summary
|
||||
|
||||
| Part | % | Remaining (h) |
|
||||
|---|---:|---:|
|
||||
| Type core (point, area, path type, Character/Paragraph, styles, threading, OpenType, fonts) | 80% | 14–19 |
|
||||
| Type advanced (CJK composition, Middle Eastern, spelling, Touch Type, Retype, variable fonts) | 50% | 20–28 |
|
||||
|
||||
## Checklist
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support |
|
||||
| Area Type Options (rows, columns, inset, first baseline, Auto Size, Shrink Text to Fit) | done | |
|
||||
| Threaded text, Fit Headline, Text Wrap | done | |
|
||||
| Type on a Path effects and options; brackets | done | #429 |
|
||||
| Character and Paragraph panels, styles with overrides | done | mixed-value display for differing paragraphs missing |
|
||||
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference unused (#394) |
|
||||
| Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339 |
|
||||
| Variable fonts | partial | named instances as styles; axis sliders missing |
|
||||
| Colour fonts (OpenType SVG / COLR) | partial | |
|
||||
| Every-line and Single-line composers | done | tuned by eye, never compared side by side |
|
||||
| Hyphenation… and Justification… options | missing | |
|
||||
| Optical Margin Alignment | missing | menu stub |
|
||||
| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); leader drawing not verified against the docs |
|
||||
| Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` |
|
||||
| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary |
|
||||
| Smart Punctuation, Change Case, Find & Replace | done | |
|
||||
| Touch Type tool | missing | listed in the toolbar, not implemented |
|
||||
| Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model |
|
||||
| Snap to Glyph | missing | menu stub |
|
||||
| Middle Eastern: bidirectional layout, Paragraph Direction | done | |
|
||||
| Middle Eastern: Character Direction, digit types, kashidas, Middle Eastern composers, split caret | missing | |
|
||||
| CJK: input methods (marked text, candidate window) | done | |
|
||||
| CJK: kinsoku (Hard, Soft, None), Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, the Mojikumi Settings dialog and the other mojikumi sets missing |
|
||||
| CJK: ruby, proportional vertical metrics (`vpal`/`palt`), manual tate-chu-yoko | missing | #966, #633 |
|
||||
| Inline symbols in text | beyond Illustrator | not scored |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | First checklist, split out of the parity table's Type core and Type advanced rows |
|
||||
@@ -0,0 +1,167 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist: pen, anchors, handles, snapping, nudging, modifiers, panels) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
|
||||
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
|
||||
how far an arrow key nudges. This checklist is the work list for the **interaction-fidelity pass**
|
||||
([gaps.md](gaps.md) G1). Part of [target-app-parity.md](target-app-parity.md).
|
||||
|
||||
**How it is measured:** from the tool state machines' own documentation and tests (`crates/tools/src/*.rs`,
|
||||
`crates/ui-egui/src/canvas.rs`, `shortcuts.rs`, the `tests_*` files) against Illustrator's public documentation
|
||||
(helpx tool pages and the keyboard-shortcut reference). Never against the running app: the clean-room rule
|
||||
forbids launching or measuring Illustrator, so there has been **no side-by-side session**. Every "done" below is
|
||||
"behaves as the documentation describes", which is weaker than "a power user can't tell". Status:
|
||||
**done** (documented behaviour implemented and tested), **partial**, **missing**.
|
||||
|
||||
## Summary
|
||||
|
||||
| Area | % | Remaining (h) | Kind |
|
||||
|---|---:|---:|---|
|
||||
| Pen, Curvature and anchor tools | 85% | 6–10 | estimated |
|
||||
| Direct Selection, handles and anchors | 85% | 5–8 | estimated |
|
||||
| Smart guides and snapping | 70% | 8–12 | estimated |
|
||||
| Transform handles, bounding box, Free Transform | 80% | 5–8 | estimated |
|
||||
| Nudging, numeric entry, precision, units | 85% | 3–5 | estimated |
|
||||
| Modifier keys and temporary tools | 80% | 5–8 | estimated |
|
||||
| Keyboard shortcuts (default set) | 70% | 6–10 | estimated |
|
||||
| Cursors | 75% | 3–5 | estimated |
|
||||
| Panels, Control bar, Properties, context menus | 70% | 15–25 | estimated |
|
||||
| Look and feel (layout, theme, metrics) | 80% | 5–10 | measured once against public screenshots on 2026-10-02; panels added since not re-measured |
|
||||
| Accessibility (screen reader, keyboard-only canvas) | 20% | 15–25 | estimated: AccessKit on by default for egui widgets; canvas and custom panels not described, never tested with a screen reader |
|
||||
| **UI/UX overall** | **~55%** | **~70–110** (overlaps the feature areas) | estimated |
|
||||
|
||||
The overall figure is lower than the rows because what users notice first (small differences across many
|
||||
tools, never checked side by side) isn't captured row by row.
|
||||
|
||||
## Pen, Curvature and anchor tools
|
||||
|
||||
| Behaviour (Illustrator, public docs) | Status | Notes |
|
||||
|---|---|---|
|
||||
| Click: corner anchor; drag: smooth anchor with symmetric handles | done | `pen.rs` |
|
||||
| Shift constrains segments and handles to 45° | done | Illustrator's Constrain Angle preference is honoured (`constrainAngle`) |
|
||||
| Alt mid-drag splits the handles (only the outgoing follows) | done | keeps the curve already shaped |
|
||||
| Space mid-drag repositions the anchor, handles and all | done | |
|
||||
| Click the first anchor to close; the drag shapes the closing curve | done | |
|
||||
| Click the last anchor to retract its outgoing handle; drag from it to pull a new one | done | |
|
||||
| Alt over a handle, anchor or segment acts as the Anchor Point tool | done | |
|
||||
| Cmd/Ctrl lends the last-used selection tool | done | the path being drawn continues afterwards |
|
||||
| Click an open path's end to continue it; click another path's end to join | done | #776 |
|
||||
| Rubber Band preview | done | Preferences option |
|
||||
| Auto Add/Delete on a selected path; Shift or the preference disables it | done | |
|
||||
| Pen cursors (new path ×, continue /, close o, add +, delete −, convert ^) | partial | vector cursors exist; not every state verified |
|
||||
| Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` |
|
||||
| Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | modifiers requested in #975 |
|
||||
| Add/Delete/Anchor Point tools; Anchor Point tool drags segments to reshape | done | |
|
||||
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | partial | Keep selected and Alt to Smooth are missing |
|
||||
| Join tool scrubbing, Average, Join with corner/smooth choice | done | |
|
||||
| Simplify (slider, auto, corner angle threshold) | done | |
|
||||
|
||||
## Direct Selection, anchors and handles
|
||||
|
||||
| Behaviour | Status | Notes |
|
||||
|---|---|---|
|
||||
| Click/Shift-click anchors; marquee toggles with Shift | done | #483 |
|
||||
| Drag a curved segment to bend it, a straight one to move its anchors | done | |
|
||||
| Handles shown are the handles that drag; tolerance from Selection & Anchor Display | done | #494 |
|
||||
| Drag a handle with Shift (45° about its anchor), Alt (move one handle alone) | done | handles snap to smart guides |
|
||||
| Show handles when multiple anchors are selected; Highlight anchors on mouse over; anchor and handle size | done | Preferences › Selection & Anchor Display |
|
||||
| Convert selected anchors to corner/smooth from the Control bar and Properties | done | |
|
||||
| Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | |
|
||||
| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | clicking a widget without pre-selecting the anchor (#908) missing |
|
||||
| Corners between curved sides, Relative rounding | missing | |
|
||||
| Isolation mode by double-click, breadcrumbs, Esc to exit | done | |
|
||||
| "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not an Illustrator feature as documented; under discussion |
|
||||
|
||||
## Smart guides and snapping
|
||||
|
||||
| Behaviour | Status | Notes |
|
||||
|---|---|---|
|
||||
| Snap to anchors, centres, edges, artboards, bleed and ruler guides with magenta lines | done | `guides.rs` kinds: Anchor, Center, Edge, Artboard, Bleed, Guide |
|
||||
| Anchor/Path labels ("anchor", "center", "path", "align") | done | |
|
||||
| "intersect" label and snapping to path intersections | missing | |
|
||||
| Construction guides at preference angles while drawing | done | #506 |
|
||||
| Measurement labels while drawing, moving and resizing | partial | sizes and offsets; angle and distance readouts while drawing missing |
|
||||
| Spacing guides (equal distances) | partial | for moves (#394); not for resizing or drawing |
|
||||
| Snapping tolerance preference | done | |
|
||||
| Snap to Point, Snap to Grid, Snap to Pixel; Pixel Preview | done | |
|
||||
| Snap to Glyph | missing | menu stub |
|
||||
| Tangent and perpendicular snapping for line endpoints (new in 30.0) | missing | |
|
||||
| Snapping panel/popover consolidating the snap options (30.0) | missing | |
|
||||
| Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only |
|
||||
| Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451 |
|
||||
| Ruler guides as Layers rows; Alt swaps a ruler guide's orientation while dragging | missing | |
|
||||
| Global and video rulers | missing | menu stubs |
|
||||
|
||||
## Transform handles, bounding box, Free Transform
|
||||
|
||||
| Behaviour | Status | Notes |
|
||||
|---|---|---|
|
||||
| Bounding-box handles: Shift proportional, Alt from centre, rotate outside the corners | done | |
|
||||
| Rotated objects keep a rotated bounding box and their angle; Reset Bounding Box | done | |
|
||||
| Free Transform, Scale and Reflect along a rotated box | missing | |
|
||||
| Free Transform widget: constrain, free distort, perspective distort; the key-held variants | done | |
|
||||
| Transform tools: click sets the reference point (snaps to anchors/centres), Alt-click opens the dialog, Alt-release copies | done | |
|
||||
| Transform Again, Transform Each, Scale Strokes & Effects, Scale Corners | done | Transform Again after a type-area resize scales the type (gap) |
|
||||
| Reference point locator (nine squares) in Control bar and Transform panel | done | |
|
||||
|
||||
## Nudging, numeric entry and precision
|
||||
|
||||
| Behaviour | Status | Notes |
|
||||
|---|---|---|
|
||||
| Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | |
|
||||
| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a focused field | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991) |
|
||||
| Arithmetic in fields (`+ - * /`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified |
|
||||
| Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) |
|
||||
| Field precision: 3 decimals for steps; values kept as f64 | done | |
|
||||
| Scrubby labels (beyond Illustrator) | done | #400 |
|
||||
| Dialogs open with the first field focused and selected; Enter confirms | done | |
|
||||
|
||||
## Modifier keys and temporary tools
|
||||
|
||||
| Behaviour | Status | Notes |
|
||||
|---|---|---|
|
||||
| Space: Hand; Cmd+Space / Cmd+Alt+Space: zoom in/out; middle button pans | done | |
|
||||
| Cmd/Ctrl: last-used selection tool; Cmd+Alt: Group Selection inside Direct Selection | partial | |
|
||||
| Alt-drag duplicates (objects, artboards with their art) | done | |
|
||||
| Shape tools: Shift constrains, Alt from centre, Space moves while dragging, arrows change sides/points/rows | done | |
|
||||
| Tool double-click opens its options | done | |
|
||||
| Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | |
|
||||
| Every tool's modifiers checked against the shortcut reference | missing | this is the fidelity pass |
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
~110 distinct modified shortcuts on menu commands plus single-key tool shortcuts for the default toolbar
|
||||
(`crates/tools/src/catalog.rs`), a shortcut editor with chords, and saved sets. Not yet diffed item by item
|
||||
against Illustrator's published default shortcut list; estimated ~70% of it. Remaining: the panel shortcuts,
|
||||
the type shortcuts (tracking/kerning/leading/baseline steps with Alt/Shift), and selection shortcuts in the
|
||||
Layers panel.
|
||||
|
||||
## Panels, Control bar, Properties, context menus
|
||||
|
||||
| Behaviour | Status | Notes |
|
||||
|---|---|---|
|
||||
| ~40 panels with ≡ menus; dock, icon column, collapse, tabs | done | 51 panel modules incl. tests |
|
||||
| Panels float inside the window, stack, dock back | done | #495 |
|
||||
| Panels outside the app window (another monitor) | missing | single OS window |
|
||||
| Properties panel per context (every selection type's Quick Actions) | partial | |
|
||||
| Contextual task bar | done | |
|
||||
| Control bar with Transform and Stroke popovers | done | |
|
||||
| Canvas context menu | done | Layers rows, panels and type have none (gap) |
|
||||
| Blend panel (30.7) | missing | Blend Options dialog exists |
|
||||
| SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) |
|
||||
| Workspaces (save, reset, switch) | done | |
|
||||
| Preferences: every option acts | partial | ~17 stored but unused (#394) |
|
||||
|
||||
## Look and feel
|
||||
|
||||
Medium Dark theme and four brightness levels, the categorized and Advanced toolbars, 35 pt document tabs,
|
||||
33 pt panel tabs, hint bar, contextual task bar and native macOS menus, restyled to values measured from
|
||||
public screenshots on 2026-10-02 (~75–80% then). Missing: a System theme that follows the OS (#825), a
|
||||
re-measure of the panels added since.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation |
|
||||
@@ -41,6 +41,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("trace", Class::Layer(2)),
|
||||
("text", Class::Layer(2)),
|
||||
("effects", Class::Layer(2)),
|
||||
("three-d", Class::Layer(2)),
|
||||
("render", Class::Layer(3)),
|
||||
("svg", Class::Layer(3)),
|
||||
("pdf", Class::Layer(3)),
|
||||
@@ -78,6 +79,7 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
|
||||
// Non-native art in Illustrator editing data is a PDF, drawn by the PDF importer.
|
||||
&["pdf", "eps"],
|
||||
&["plugins", "effects"],
|
||||
&["three-d", "effects"],
|
||||
];
|
||||
|
||||
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
|
||||
Reference in new issue
Block a user