diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 706daac6d..c7b032f07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -377,11 +377,91 @@ jobs: path: dist/release/* if-no-files-found: error + linux-riscv64: + name: Linux riscv64 + needs: version + # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release + # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's + # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the + # CLI is smoke-tested under QEMU (package.sh runs `vectorcraft-cli --version` through $EMULATOR). + runs-on: ubuntu-24.04 + # No `environment: release`: this job signs nothing, so it gets no release secrets. + env: + VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }} + VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v7 + # Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build + # step). Bump the commit deliberately; see craftrules standards/fonts.md. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - uses: dtolnay/rust-toolchain@stable + with: + targets: riscv64gc-unknown-linux-gnu + - uses: Swatinem/rust-cache@v2 + with: + key: release-linux-riscv64 + - name: Apply version override + if: inputs.version != '' + run: cargo xtask version set "$VECTORCRAFT_VERSION" + - name: Install cross-compilation dependencies + run: | + . /etc/os-release + sudo dpkg --add-architecture riscv64 + # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + fi + if [ -f /etc/apt/sources.list ]; then + sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list + sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list + fi + for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do + echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" + done | sudo tee /etc/apt/sources.list.d/riscv64.list + sudo apt-get update + # The riscv64 variants of the linux job's -dev packages. + sudo apt-get install -y --no-install-recommends \ + gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ + libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ + libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \ + desktop-file-utils appstream + - name: Build and package (tar.gz) + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc + CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc + CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++ + AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar + PKG_CONFIG_SYSROOT_DIR: / + PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig + PKG_CONFIG_ALLOW_CROSS: "1" + CROSS_ARCH: riscv64 + CROSS_TARGET: riscv64gc-unknown-linux-gnu + CROSS_COMPILE: riscv64-linux-gnu- + EMULATOR: qemu-riscv64 + QEMU_LD_PREFIX: /usr/riscv64-linux-gnu + run: packaging/linux/package.sh --formats tar + - name: Check the package + run: | + tar -tzf dist/release/vectorcraft-*-linux-riscv64.tar.gz \ + | grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$' + file target/riscv64gc-unknown-linux-gnu/release/vectorcraft | grep -F 'RISC-V' + - uses: actions/upload-artifact@v7 + with: + name: release-linux-riscv64 + path: dist/release/*.tar.gz + if-no-files-found: error + release: name: Draft GitHub Release # Needs the signed jobs, so a branch dry run (where the environment refuses them) never # drafts a release. - needs: [version, macos, windows, linux, flatpak, freebsd, web] + needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] runs-on: ubuntu-latest environment: release permissions: diff --git a/AGENTS.md b/AGENTS.md index ec28c2d4a..1731a4d39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else. ## Start every session here -1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list. +1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list. 2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it. 3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's. @@ -56,10 +56,15 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`). -## Roadmap -`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates. -- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank. -- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low. +## Roadmap and progress docs +The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): +- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log. +- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships). +- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus. +- Checklists: [`ui-parity.md`](docs/ui-parity.md), [`file-format-parity.md`](docs/file-format-parity.md), [`hardware-parity.md`](docs/hardware-parity.md), [`localization-parity.md`](docs/localization-parity.md), [`effects-parity.md`](docs/effects-parity.md), [`type-parity.md`](docs/type-parity.md); [`architecture.md`](docs/architecture.md). + +When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history. +- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated. - Keep the README's Status section in step with the ROADMAP headline. ## Contributor credits (About window) diff --git a/CLAUDE.md b/CLAUDE.md index 932d0b6d6..ca12182b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else. ## Start every session here -1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list. +1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list. 2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it. 3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's. @@ -56,8 +56,13 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`). -## Roadmap -`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates. -- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank. -- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low. +## Roadmap and progress docs +The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): +- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log. +- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships). +- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus. +- Checklists: [`ui-parity.md`](docs/ui-parity.md), [`file-format-parity.md`](docs/file-format-parity.md), [`hardware-parity.md`](docs/hardware-parity.md), [`localization-parity.md`](docs/localization-parity.md), [`effects-parity.md`](docs/effects-parity.md), [`type-parity.md`](docs/type-parity.md); [`architecture.md`](docs/architecture.md). + +When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history. +- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated. - Keep the README's Status section in step with the ROADMAP headline. diff --git a/Cargo.lock b/Cargo.lock index 1707a65db..c8257cdf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2270,9 +2270,9 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" [[package]] name = "linesweeper" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769" +checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18" dependencies = [ "arrayvec", "kurbo 0.13.1", @@ -4433,7 +4433,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.8.0" +version = "0.9.0" dependencies = [ "arboard", "block2 0.6.2", @@ -4463,7 +4463,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.8.0" +version = "0.9.0" dependencies = [ "crc32fast", "flate2", @@ -4474,7 +4474,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.8.0" +version = "0.9.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -4486,7 +4486,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4502,7 +4502,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.8.0" +version = "0.9.0" dependencies = [ "log", "objc2-core-foundation", @@ -4523,7 +4523,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.8.0" +version = "0.9.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -4535,7 +4535,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "jpeg-encoder", @@ -4552,7 +4552,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.8.0" +version = "0.9.0" dependencies = [ "kurbo 0.13.1", "serde_json", @@ -4562,11 +4562,12 @@ dependencies = [ "vectorcraft-pathops", "vectorcraft-plugins", "vectorcraft-text", + "vectorcraft-three-d", ] [[package]] name = "vectorcraft-engine" -version = "0.8.0" +version = "0.9.0" dependencies = [ "crc32fast", "flate2", @@ -4601,7 +4602,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flate2", "image", @@ -4621,7 +4622,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flate2", "proptest", @@ -4635,7 +4636,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.8.0" +version = "0.9.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -4647,7 +4648,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "log", @@ -4666,7 +4667,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4680,7 +4681,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.8.0" +version = "0.9.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -4692,7 +4693,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.8.0" +version = "0.9.0" dependencies = [ "aes", "flate2", @@ -4721,7 +4722,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.8.0" +version = "0.9.0" dependencies = [ "serde", "serde_json", @@ -4735,7 +4736,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flate2", "gif", @@ -4760,7 +4761,7 @@ dependencies = [ [[package]] name = "vectorcraft-svg" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flate2", "image", @@ -4784,7 +4785,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4801,7 +4802,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.8.0" +version = "0.9.0" dependencies = [ "harfrust 0.13.3", "kurbo 0.13.1", @@ -4817,9 +4818,16 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "vectorcraft-three-d" +version = "0.9.0" +dependencies = [ + "vectorcraft-geom", +] + [[package]] name = "vectorcraft-tools" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4834,7 +4842,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4847,7 +4855,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.8.0" +version = "0.9.0" dependencies = [ "craft-ui", "egui", @@ -4873,13 +4881,14 @@ dependencies = [ "vectorcraft-svg", "vectorcraft-testkit", "vectorcraft-text", + "vectorcraft-three-d", "vectorcraft-tools", "wat", ] [[package]] name = "vectorcraft-web" -version = "0.8.0" +version = "0.9.0" dependencies = [ "eframe", "egui", @@ -5989,7 +5998,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.8.0" +version = "0.9.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 8dba9a6be..748ecb6e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"] [workspace.package] -version = "0.8.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -21,6 +21,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" } @@ -61,7 +62,7 @@ usvg = "0.48" krilla = "0.8" skrifa = "0.47" harfrust = "0.13" -linesweeper = "0.4" +linesweeper = "0.5" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } # Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art. ruzstd = { version = "0.9", default-features = false, features = ["std"] } diff --git a/README.md b/README.md index 79b26dbbc..0d0457fdd 100644 --- a/README.md +++ b/README.md @@ -198,11 +198,14 @@ For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instruc ## Status -VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the -milestones, and honest time-to-parity estimates. +VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the stage, the numbers by dimension +and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and +[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing. -**Where we are (2026-10-10):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of -"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools, +**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist +(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against +all of Illustrator): everyday +vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package). Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open @@ -213,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, German, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus). -The scores are -self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them. +speaks English, German, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus); +[localization-parity.md](docs/localization-parity.md) has the details. +The scores are self-assessed from our code against Illustrator's public documentation; +[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. **What's missing:** -- 3D and Materials; -- the Photoshop-style raster effects (Effect Gallery); +- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written); +- an interaction-fidelity pass covering every tool's modifiers and small behaviours; +- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); +- 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); -- an interaction-fidelity pass covering every tool's modifiers and small behaviours; -- packaging for Windows and Linux. +- generative AI features. -**Where we're going:** next is the interaction-fidelity pass alongside the raster-effects package, then 3D and -advanced type, then hardening and packaging for 1.0. The prioritized list is in -[Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order). +**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability, +alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in +[`docs/gaps.md`](docs/gaps.md). -**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}` +**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}` and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so the UI can be swapped without touching the engine. diff --git a/ROADMAP.md b/ROADMAP.md index ed522733f..c25b14cbd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,279 +4,150 @@ - UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned. -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 window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2), Variables (data merge): text and visibility variables, data sets and the Variables panel (#761); see [Honest assessment](#honest-assessment-2026-10-05))._ +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 (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting | -| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | -| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | -| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing | -| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | -| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | -| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) | -| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) | -| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) | +| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 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 36 filters (Artistic, Brush Strokes, Sketch, - Stylize) and the Effect Gallery. The pixel-filter pipeline is in place - (`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and - Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur - (Normal mode only), Distort › Diffuse Glow, Glass (its Blocks, Canvas, Frosted and Tiny Lens surfaces made in - code; Load Texture is missing) and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, - Stylize › Glowing Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (its - Brick, Burlap, Canvas and Sandstone surfaces made in code; Load Texture is missing) and Video › De-Interlace and - NTSC Colors; each new filter is now one function, a catalogue row, menu and translations. Parallelizes - well across agents. 19–31 h. -3. **3D and Materials:** 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:** a scripting surface over the command registry, batch, and the rest of - Variables (data merge): image and graph variable kinds, dataset import/export. 6–10 h. -7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video - rulers. 15–25 h. -8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, Affinity files from more versions and platforms - (51 pinned files now include 28 original Affinity 3.2.3 feature probes; large interacting documents, - complex typography and independently paired `.af`/PSD renders reopened in Affinity remain missing), idle-machine perf budgets, Windows, Linux and - browser QA, pen tablets on Wayland and pen pressure beyond Windows (winit 0.31's tablet input, #491), accessibility, packaging. 50–80 h. +Hours are Opus 5.5 agent wall-clock hours, calibrated from this repo's merged-PR timestamps (e.g. 0.7–1.2 h per +Photoshop-style filter, ~0.8 h per medium feature); see [calibration](docs/target-app-parity.md#calibration). +The installed Illustrator was **not** inspected: the clean-room rule in [`AGENTS.md`](AGENTS.md) forbids it, so +everything is measured from our code against Illustrator's public documentation. -### Where we're going +## By dimension -- **Next:** start the interaction-fidelity pass (1) in parallel with the raster-effects package (2). Both are broad, - so they split well across agents, and they move the "can't tell the difference" number most. -- **Then:** 3D (3) and advanced type (4), each a focused milestone with its own plan in `plan/` before coding. -- **Alongside:** keep closing the stubbed menu items. Run `vectorcraft-cli perf` on an idle machine, and fix any - budget it misses before 1.0. -- **1.0:** feature parity plus the fidelity pass, hardening (8) and packaging for all three desktop OSes and the web. +| Dimension | % | One agent (h) | Doc | +|---|---:|---:|---| +| Features (depth) | 75% | 220–360 | [target-app-parity.md](docs/target-app-parity.md) | +| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) | +| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) | +| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) | +| Localization (12 key languages) | 54% (measured) | 70–110 | [localization-parity.md](docs/localization-parity.md) | +| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) | +| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) | +| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) | +| Ecosystem and plug-ins | 15% | 30–60 | [gaps.md › G17](docs/gaps.md#g17-ecosystem-third-party-plug-ins) | +| AI features (generative) | 0% | 30–60 + owner decision | [gaps.md › G18](docs/gaps.md#g18-generative-ai-features) | +| Agent automation | beyond Illustrator | — | [mcp.md](docs/mcp.md) | -**For agents:** pick work from the list above (or the milestone rows below), and write a task plan before coding. -When a task lands, update this section, the parity table and "Shipped so far" in the same PR. Keep the scores honest: -grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. +The dimension hours overlap (feature rows hold some UI, format and AI work), so they don't add up to the total. -## Shipped so far +## Features -- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. -- **Automation:** - - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - - Variables (data merge): text and visibility variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility Dynamic, Capture/Update Data Set, and a row in italics when an edit leaves the art not matching it; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. - - JSON-lines control channel with real egui pointer and keyboard injection. - - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832). - - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785). - - Actions panel that records and plays back commands. -- **UI:** Illustrator 2026 layout restyled to measured values: - - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - - Hint bar, contextual task bar, 19 dock panels with ≡ menus. - - The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it). - - Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it. - - Panels undock (#495): ordinary tabs and panel icons can be dragged into floating groups, reordered, merged into another dock group, or placed beside it as a split. Floating groups stay inside the application window and can be resized; Dock Panel restores a panel’s saved placement. The default dock keeps its icon rail, group grip and Tools behavior, and custom layouts keep access to panels through the rail and Window menu. Grouping, split sizes, floating geometry and return locations persist with workspaces and preferences; UI gestures and control/MCP commands use the same docking actions. The Tools panel still floats by its title bar and docks on the left edge. Not done: panels in separate OS windows and collapsing a floating group to icons. - - 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. - - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). -- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). -- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. -- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. -- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. -- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). -- **Paint and appearance (M3):** - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. - - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). - - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. - - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. - - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. - - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. - - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. - - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. - - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. -- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. -- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). -- **Files (M4):** - - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). - - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. - - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [scope, provenance and limits](crates/affinity/README.md). - - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. - - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). -- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. -- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland. -- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). +| Area | % | One agent (h) | +|---|---:|---:| +| Selection, transform & align | 85% | 4–6 | +| Drawing tools | 85% | 8–13 | +| Path operations, Pathfinder, Shape Builder, Live Paint | 80% | 4–8 | +| Colour, swatches, gradients, patterns, mesh, recolor | 92% | 4–7 | +| Strokes, brushes, width profiles | 88% | 8–15 | +| Appearance, transparency, graphic styles, masks | 97% | 1–2 | +| Live vector effects | 85% | 6–10 | +| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 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 ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | -| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 | -| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; graph Design/Column/Marker designs pending | 6–10 | -| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | -| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 | -| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 | -| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** | +## Languages -## Parity estimate +| Language | Code | Status | Translated | +|---|---|---|---:| +| English | `en` | full | 100% | +| Simplified Chinese | `zh-hans` | partial (messages in English) | 85% | +| Spanish | `es` | full | 100% | +| Hindi | `hi` | none | 0% | +| Arabic | `ar` | none (no right-to-left interface) | 0% | +| French | `fr` | full | 100% | +| Portuguese (Brazil) | `pt-br` | partial | 68% | +| Indonesian | `id` | none | 0% | +| Japanese | `ja` | full | 100% | +| German | `de` | full | 98% | +| Korean | `ko` | none | 0% | +| Vietnamese | `vi` | none | 0% | -_Method (2026-10-02; M3 rows re-derived 2026-10-04, file, export, print, links and UI-chrome rows after M4 on 2026-10-05):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of -real users' work) each represents. Each area is scored by depth of behaviour, not by presence of a menu item: an area -is 100% only when every feature in it behaves like Illustrator. Feature parity = Σ weight × score / Σ weight. Remaining -time is counted per area from what is missing, calibrated on measured throughput: in the last session one agent landed -about 20 medium features (Text Wrap, Graphs, Effect → Pathfinder, export baking…) in ~16 h wall clock including builds -and CI on a heavily loaded machine — about 0.8 h per medium feature; large subsystems (3D, raster filters, vertical type) -are counted bottom-up. The M3 rows (colour, strokes, appearance) come from a detailed 92-task M3 plan whose review -found more missing depth than the first estimate; all 92 tasks landed on 2026-10-04 and 2026-10-05 with 6–8 agents in parallel; its packages, planned at 5–11 h each, took -about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) are likely high. +Also shipped: Traditional Chinese (partial, 80%), Italian, Russian and Ukrainian (full), Czech (menus only). +No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md). -| Area | Weight | Done | Missing (main items) | One agent (h) | -|---|---:|---:|---|---:| -| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 | -| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 | -| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | -| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | -| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 | -| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | -| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 | -| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 | -| 3D and Materials | 4 | 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 | 65% | scripting surface, batch (Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | -| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 13–20 | -| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 | -| **Feature parity** | **103** | **~75%** | | **~210–330** | -| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 | -| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 | -| **Full parity** | | **~56%** | | **~320–500** | +## Upcoming -With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4 -(integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity, -**~80–145 h** to full parity. +Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md). -_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool -implemented except Touch Type; 51 panel modules; Illustrator-style live -effects ~44/54, Photoshop-style raster effects ~4/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust. +1. **Interaction-fidelity pass** (G1), tool by tool from [ui-parity.md](docs/ui-parity.md): 60–90 h. +2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h. +3. **Launch and stability on real machines** (G10): 20–35 h. +4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h. +5. **Raster effects** (G2) alongside: 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). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). | +| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | +| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | +| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | +| 2026-10-07 | v0.4.0; issue fixes; Layers panel rework | +| 2026-10-06 | M8 transform and distort fidelity pass (M8.1–M8.20) | +| 2026-10-05 | M4 files done (M4.14–M4.98); M3 paint and appearance done (92 tasks over two days) | +| 2026-10-02 | First weighted parity estimate (22 areas); look and feel measured against public screenshots | +| 2026-10-01 | Renamed from DrawCraft to VectorCraft | +| 2026-09-30 | Repository history begins | -## Where we aim to be better than Illustrator -- **Speed:** off-thread multithreaded rendering, instant startup, a responsive UI on huge files. -- **Robustness:** exact curve booleans (no "cannot perform operation"), property-tested undo/redo and file round trips. -- **Openness:** a documented native format, first-class SVG, and the same app in the browser. -- **Automation:** every command, gesture, dialog and widget is drivable by agents (JSON channel + MCP), with headless batch mode. +The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/roadmap.md#progress-log-before-the-progress-docs-standard). -## How to update this file -After each milestone task lands, update the status column and remaining estimates, and move items into "Shipped so far". Keep estimates honest: re-derive them from what remains, never from wishful velocity. +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Method aligned with the standard, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away | +| 2026-10-10 | minor | Added the essentials-user readiness score (~75%) | +| 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md | +| 2026-10-10 | minor | Applied the core-workflow gate (docs/roadmap.md › Alpha gate): passes, stage stays alpha | +| 2026-10-10 | major | Re-measured against Illustrator 2026 (30.x) from code counts, 71 open issues and public docs; stage set to alpha; moved the parity estimate, honest assessment and "Shipped so far" to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, milestones to `docs/roadmap.md`, the `.ai` scope to `docs/file-format-parity.md` | +| 2026-10-05 | major | Honest assessment by dimension and the prioritized gap list | +| 2026-10-02 | major | First 22-area weighted parity estimate | diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 1cf07964a..26139dcc8 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -292,7 +292,21 @@ fn run(args: &[String]) -> Result<(), String> { let mut h = Headless::new(); let mut out = std::io::stdout().lock(); - let mut emit = |v: Value| writeln!(out, "{v}").map_err(|e| e.to_string()); + // A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later + // steps (an export…) still run, unprinted. + let mut closed = false; + let mut emit = |v: Value| { + if closed { + return Ok(()); + } + match writeln!(out, "{v}") { + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => { + closed = true; + Ok(()) + } + r => r.map_err(|e| e.to_string()), + } + }; if let Some(path) = &input { let r = h.call("app.open", json!({"path": path})).map_err(|e| format!("open {path}: {e}"))?; emit(json!({"step": "open", "path": path, "result": r}))?; diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index d5bb25783..5b17de4bc 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -297,3 +297,21 @@ fn closed_stdout_ends_quietly() { let err = String::from_utf8_lossy(&out.stderr); assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status); } + +/// #1005: `run` whose reader has gone (`vectorcraft-cli run … | true`) ends quietly with status 0, +/// and its later steps still run; a failing step still fails. +#[test] +fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() { + let svg = tmp("closed-run.svg"); + let _ = std::fs::remove_file(&svg); + let rect = r#"{"x":1,"y":1,"width":5,"height":5}"#; + let args = ["run", "--cmd", "document.json", "--cmd", "shape.rectangle", "--params", rect, "--export", svg.to_str().unwrap()]; + let mut child = Command::new(BIN).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + drop(child.stdout.take()); + let out = child.wait_with_output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success() && err.is_empty(), "{:?}: {err}", out.status); + assert!(svg.is_file(), "the export after the closed reader still ran"); + let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap(); + assert!(!out.status.success()); +} diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index 264583ed5..2442a4e98 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -26,9 +26,16 @@ //! the graphics fail while the window is starting up, [`finish`] starts the app again without //! that adapter, as the next one in the order would be tried. Each restart leaves out one more //! adapter, so it ends when none is left. +//! +//! An adapter can also hang instead of failing: on a hybrid laptop with High Performance chosen, +//! the discrete GPU never put the window's first frame on the screen, with no error and no panic, +//! and the app stayed in the background without a window (#964). [`watch_first_frame`] gives the +//! first frame [`FIRST_FRAME_TIMEOUT`] once the app is created and, when it doesn't come, starts +//! the app again without that adapter in the same way. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; use eframe::egui_wgpu::NativeAdapterSelectorMethod; use eframe::wgpu::{self, Backend, Backends, DeviceType, PowerPreference}; @@ -50,6 +57,11 @@ const HELP: &str = /// window is something else (such as the compositor restarting). pub const STARTUP_FRAMES: u64 = 10; +/// How long the window's first frame may take to reach the screen, from the app's creation, +/// before its adapter counts as hanging ([`watch_first_frame`]). The first frame draws the whole +/// interface and compiles its shaders, a second at most on a slow machine. +pub const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(20); + /// The preference used when Preferences say Automatic. Windows and macOS show frames from any GPU /// (the integrated one avoids the flicker of #306). Elsewhere, the system's own order: Mesa's /// device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid @@ -311,6 +323,8 @@ pub struct Startup { adapter: OnceLock, /// The UI's context, which counts the frames run. ui: OnceLock, + /// When the app was created, the files named at the start opened: the first frame is due. + ready: OnceLock, } impl Startup { @@ -319,6 +333,71 @@ impl Startup { // Set once; the window is created once. let _ = self.ui.set(ctx.clone()); } + + /// The app is ready to draw its first frame. + pub fn ready(&self) { + // Set once, like `created`. + let _ = self.ready.set(Instant::now()); + } +} + +/// What [`watch_first_frame`] makes of the window's start. +#[derive(Debug, PartialEq, Eq)] +enum FirstFrame { + /// Not due yet. + Wait, + /// On the screen (the second frame began, so the first was presented), or the window was + /// minimized at the start: nothing to watch. + Shown, + /// Overdue. + Hung, +} + +/// The window's start after `frames` frames run, `since` its first frame was due. +fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame { + if frames >= 2 || minimized { + FirstFrame::Shown + } else if since >= FIRST_FRAME_TIMEOUT { + FirstFrame::Hung + } else { + FirstFrame::Wait + } +} + +/// Watch the window's first frame from another thread: when it hasn't reached the screen +/// [`FIRST_FRAME_TIMEOUT`] after the app was created ([`Startup::ready`]), start the app again +/// without the adapter, as [`finish`] does for one that failed, and end this process, whose UI +/// thread is stuck in the graphics driver. +pub fn watch_first_frame(startup: Arc) { + let watch = move || loop { + std::thread::sleep(Duration::from_millis(250)); + let Some(due) = startup.ready.get() else { continue }; + let ui = startup.ui.get(); + let frames = ui.map_or(0, egui::Context::cumulative_frame_nr); + let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false); + match first_frame(frames, due.elapsed(), minimized) { + FirstFrame::Wait => continue, + FirstFrame::Shown => return, + FirstFrame::Hung => {} + } + let mut skip = skipped(); + let Some(failed) = restart_without(true, frames, startup.adapter.get().map(String::as_str), &skip) else { return }; + log::error!( + "the window's first frame didn't reach the screen in {}s on graphics adapter {failed}: starting again without it", + FIRST_FRAME_TIMEOUT.as_secs() + ); + skip.push(failed.to_string()); + match restart(&skip.join(",")) { + Ok(()) => std::process::exit(0), + Err(e) => { + log::error!("starting again failed: {e}"); + return; + } + } + }; + if let Err(e) = std::thread::Builder::new().name("first frame".into()).spawn(watch) { + log::warn!("no watch on the window's first frame: {e}"); + } } /// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPUs that drive a @@ -729,6 +808,24 @@ mod tests { assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); } + #[test] + fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() { + let s = Duration::from_secs; + // The second frame began: the first was presented. + assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown); + // Still drawing (or presenting) the first frame: waited for, then given up on (#964). + for frames in [0, 1] { + assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait); + assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung); + } + // A window minimized at the start may not draw again until restored. + assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown); + // A hung adapter is left out once, like one that failed. + let skip = vec!["Dx12:10de:28a0".to_string()]; + assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); + assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8")); + } + #[test] fn a_restart_tries_the_next_adapter() { let c = issue_502(); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 4589a2e29..e277f0e3f 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -189,7 +189,42 @@ fn read_prefs() -> Option { return None; } let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?; - serde_json::from_slice(&bytes).ok() + decode_saved_prefs(&bytes) +} + +/// Recover only optional persisted docking metadata. Runtime UiState and command decoding +/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences. +fn decode_saved_prefs(bytes: &[u8]) -> Option { + use serde_json::{Map, Value, json}; + + fn recover(object: &mut Map, hidden_key: &str) { + if let Some(layout) = object.get("docking") + && serde_json::from_value::(json!({"docking": layout})).is_err() + { + object.remove("docking"); + } + if let Some(hidden) = object.get_mut(hidden_key) { + if let Value::Object(entries) = hidden { + entries.retain(|panel, location| { + serde_json::from_value::(json!({"docking_hidden": {panel: location}})).is_ok() + }); + } else { + object.remove(hidden_key); + } + } + } + + let mut value: Value = serde_json::from_slice(bytes).ok()?; + let object = value.as_object_mut()?; + recover(object, "docking_hidden"); + if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") { + for workspace in workspaces { + if let Some(object) = workspace.as_object_mut() { + recover(object, "dockingHidden"); + } + } + } + serde_json::from_value(value).ok() } fn load_prefs(app: &mut VectorcraftApp, saved: Option) { @@ -504,7 +539,9 @@ fn main() -> std::process::ExitCode { #[cfg(feature = "wgpu")] let displays = gpu::preferred_displays(gpu_pref, power_env); #[cfg(feature = "wgpu")] - if gpu::automatic(gpu_pref, power_env) { + let chosen_gpu = !gpu::automatic(gpu_pref, power_env); + #[cfg(feature = "wgpu")] + if !chosen_gpu { let listed: Vec = displays.iter().map(ToString::to_string).collect(); log::info!("display GPUs (PCI vendor:device): {}", if listed.is_empty() { "unknown".to_string() } else { listed.join(", ") }); } else { @@ -534,6 +571,8 @@ fn main() -> std::process::ExitCode { }; #[cfg(feature = "wgpu")] gpu::watch_panics(); + #[cfg(feature = "wgpu")] + gpu::watch_first_frame(startup.clone()); // Files opened from Finder and the Dock arrive as events, not arguments. #[cfg(target_os = "macos")] open_documents::install(); @@ -577,7 +616,15 @@ fn main() -> std::process::ExitCode { log::info!("rendering with {summary} (power preference {power:?})"); created.created(&cc.egui_ctx); if !gpu::skipped().is_empty() { - app.status(format!("The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}")); + // A processor chosen in Settings can be the one that can't show the window. + let hint = if chosen_gpu { + " (Settings › Performance › Graphics Processor › Automatic uses the one that drives your display)" + } else { + "" + }; + app.status(format!( + "The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}{hint}" + )); } app.graphics_adapter = Some(summary); let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone()); @@ -609,6 +656,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 })) }), ) @@ -634,6 +684,51 @@ mod tests { vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() } } + #[test] + fn unreadable_saved_docking_keeps_preferences_and_every_workspace() { + use serde_json::json; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + app.ui.toolbar = false; + app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"}); + app.ui.custom_workspaces = + vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")]; + let original = serde_json::to_value(&app.ui).unwrap(); + let valid_location = original["docking_hidden"]["layers"].clone(); + assert!(valid_location.is_object()); + for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] { + for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] { + let mut raw = original.clone(); + raw["docking"] = invalid_layout.clone(); + raw["docking_hidden"] = invalid_hidden.clone(); + raw["custom_workspaces"][0]["docking"] = invalid_layout.clone(); + raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone(); + assert!(serde_json::from_value::(raw.clone()).is_err(), "runtime serde stays strict"); + let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap(); + assert_eq!(loaded.engine_prefs, app.ui.engine_prefs); + assert!(!loaded.toolbar); + assert!(loaded.docking.is_none()); + assert!(!loaded.docking_hidden.contains_key("layers")); + if invalid_hidden.is_object() { + assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location); + } else { + assert!(loaded.docking_hidden.is_empty()); + } + assert_eq!(loaded.custom_workspaces.len(), 2); + assert_eq!(loaded.custom_workspaces[0].name, "Future"); + assert!(loaded.custom_workspaces[0].docking.is_none()); + assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers")); + assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]); + } + } + let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(roundtrip).unwrap(), original); + assert!( + decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(), + "unrelated fields retain normal strict parsing" + ); + } + #[test] fn the_system_title_bar_preference_keeps_the_window_decorations() { // Preferences › User Interface › System Title Bar gives the window back its system diff --git a/contributors/people.toml b/contributors/people.toml index 7477eff37..c8c0772b3 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,9 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.lollinoo] +sync_github_name = true + [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas" @@ -58,3 +61,7 @@ sync_github_name = false real_name = "Fabio William Conceição" display_name = "Messhias" sync_github_name = false + +[people.KattKnight] +display_name = "KattKnight (ayla.dsc)" +sync_github_name = false diff --git a/crates/cad/src/import.rs b/crates/cad/src/import.rs index 721586e5f..b79670934 100644 --- a/crates/cad/src/import.rs +++ b/crates/cad/src/import.rs @@ -129,7 +129,7 @@ fn default_ratio(h: &reader::Header) -> (Unit, f64) { /// The name of `$INSUNITS` code `code`. fn units_name(code: i64) -> &'static str { - const NAMES: [&str; 22] = [ + const NAMES: [&str; 25] = [ "Unitless", "Inches", "Feet", @@ -152,6 +152,9 @@ fn units_name(code: i64) -> &'static str { "Light years", "Parsecs", "US survey feet", + "US survey inches", + "US survey yards", + "US survey miles", ]; usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless") } diff --git a/crates/cad/src/import/build.rs b/crates/cad/src/import/build.rs index 449b95f7c..404b6a161 100644 --- a/crates/cad/src/import/build.rs +++ b/crates/cad/src/import/build.rs @@ -66,6 +66,8 @@ pub(crate) struct Build<'d, 'a> { /// The unit of `$INSUNITS` code `code` in points (0, unitless: none). pub(crate) fn insunits_points(code: i64) -> Option { let m = 72.0 / 0.0254; + // The US survey foot: exactly 1200/3937 m. + let survey_ft = m * 1200.0 / 3937.0; Some(match code { 1 => 72.0, 2 => 864.0, @@ -87,7 +89,10 @@ pub(crate) fn insunits_points(code: i64) -> Option { 18 => m * 1.495_978_707e11, 19 => m * 9.460_730_472_580_8e15, 20 => m * 3.085_677_581e16, - 21 => m * 1200.0 / 3937.0, + 21 => survey_ft, + 22 => survey_ft / 12.0, + 23 => survey_ft * 3.0, + 24 => survey_ft * 5280.0, _ => return None, }) } diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index 0e69cb546..5cddf427e 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -128,6 +128,20 @@ fn units_choose_the_default_scale() { let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap(); assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns")); assert!((um.scale - 1000.0).abs() < 1e-6); + // #1011: US survey inches, yards and miles (from the survey foot, 1200/3937 m), not unitless. + let survey_ft = 72.0 / 0.0254 * 1200.0 / 3937.0; + for (code, name, pt) in [ + ("21", "US survey feet", survey_ft), + ("22", "US survey inches", survey_ft / 12.0), + ("23", "US survey yards", survey_ft * 3.0), + ("24", "US survey miles", survey_ft * 5280.0), + ] { + let i = info(&drawing(&[(9, "$INSUNITS"), (70, code)], &[], &[], LINE)).unwrap(); + assert_eq!((i.unit, i.units.as_str()), (Unit::Millimeters, name)); + assert!((i.scale * pt - 72.0 / 25.4).abs() < 1e-9, "{name}: 1 mm is {} units", i.scale); + } + let survey_in = open(&drawing(&[(9, "$INSUNITS"), (70, "22")], &[], &[], LINE), &ImportOptions::default()).document; + assert!((survey_in.artboards[0].rect.width() - 7200.0144000288).abs() < 1e-6, "{:?}", survey_in.artboards[0].rect); let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap(); assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0)); // A ratio: 1 cm = 10 units is 1:1 in millimetres again. diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 3f9108723..1c2b96c6e 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -131,10 +131,18 @@ impl Gradient { std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid) }) } + /// The same ramp the other way round: the stops in reverse order at mirrored offsets, each + /// segment's midpoint mirrored with it. pub fn reverse(&mut self) { self.stops.reverse(); - for s in &mut self.stops { - s.offset = 1.0 - s.offset; + for i in 0..self.stops.len() { + // A midpoint belongs to the segment after its stop: reversed, stop i's segment is the + // one the next stop (not yet mirrored) had, measured from the other end. + let mid = self.stops.get(i + 1).map_or(0.5, |n| 1.0 - n.midpoint); + if let Some(s) = self.stops.get_mut(i) { + s.offset = 1.0 - s.offset; + s.midpoint = mid; + } } } pub fn sort(&mut self) { @@ -569,6 +577,32 @@ mod tests { assert_eq!(g.stops[0].offset, 0.0); } + /// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same + /// the other way round. + #[test] + fn reverse_mirrors_midpoints() { + let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; + let two = Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)] }; + let three = + Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)] }; + for g in [two, three] { + let mut r = g.clone(); + r.reverse(); + let mids: Vec = r.stops.iter().map(|s| s.midpoint).collect(); + let want: Vec = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect(); + assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}"); + for i in 0..=20 { + let t = i as f32 / 20.0; + let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t)); + let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1); + assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}"); + } + r.reverse(); + let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6); + assert!(back, "reversed twice, it is as it was: {:?}", r.stops); + } + } + fn close(a: Point, b: Point) -> bool { a.distance(b) < 1e-9 } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3c555def9..08567cda4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -402,12 +402,17 @@ pub struct Guide { /// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas. #[serde(default, skip_serializing_if = "Option::is_none")] pub artboard: Option, + /// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed + /// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides + /// from before guides had layers, and from files that don't say), always there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer: Option, } impl Guide { /// A canvas guide. pub fn new(vertical: bool, pos: f64) -> Self { - Self { vertical, pos, artboard: None } + Self { vertical, pos, artboard: None, layer: None } } /// Moved by `d` (a vertical guide across, a horizontal one down). @@ -990,6 +995,73 @@ impl Document { let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect; Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }) } + /// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has. + fn guide_layer(&self, g: &Guide) -> Option { + g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) + } + /// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is + /// visible? A guide whose layer is gone shows as one on no layer. + pub fn guide_shown(&self, g: &Guide) -> bool { + self.guide_layer(g).is_none_or(|l| self.is_visible(l)) + } + /// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked? + pub fn guide_editable(&self, g: &Guide) -> bool { + self.guide_layer(g).is_none_or(|l| self.is_editable(l)) + } + /// Put the ruler guides on no layer this document has (from files saved before guides had + /// layers, or of layers merged into `layer`) on layer `layer`. Returns how many. + pub fn rehome_guides(&mut self, layer: NodeId) -> usize { + let lost: Vec = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect(); + let mut n = 0; + for (g, lost) in self.guides.iter_mut().zip(lost) { + if lost { + g.layer = Some(layer); + n += 1; + } + } + n + } + /// Put the ruler guides on no layer (from files saved before guides had layers), or on one that + /// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many. + pub fn adopt_guides(&mut self) -> usize { + self.default_layer().map_or(0, |home| self.rehome_guides(home)) + } + /// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in + /// step. Guides on no layer stay. Returns how many went. + pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize { + let gone: Vec = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect(); + self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false)) + } + /// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made + /// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies + /// the objects on it. Returns how many. + pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize { + let layers = |n: &Node| { + let mut out = vec![]; + n.walk(&mut |c| { + if c.is_layer() { + out.push(c.id); + } + }); + out + }; + let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect(); + let copies: Vec = self + .guides + .iter() + .filter_map(|g| { + let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1; + Some(Guide { layer: Some(to), ..g.clone() }) + }) + .collect(); + let n = copies.len(); + self.guides.extend(copies); + n + } + /// The ruler guides on layer `layer` (not its sublayers'), by index. + pub fn guides_on(&self, layer: NodeId) -> impl Iterator + '_ { + self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer)) + } /// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)? pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool { let along = if g.vertical { p.y } else { p.x }; diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index a64b17aa5..0275953a1 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -29,8 +29,9 @@ pub struct Selection { /// clears them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub slices: Vec, - /// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. They are - /// selected on their own: any change to the object selection clears them. + /// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. A new + /// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so + /// guides and objects can be selected together, in either order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub guides: Vec, } @@ -66,7 +67,6 @@ impl Selection { pub fn add(&mut self, id: NodeId) { self.target = None; self.slices.clear(); - self.guides.clear(); if !self.objects.contains(&id) { self.objects.push(id); } @@ -74,7 +74,6 @@ impl Selection { pub fn remove(&mut self, id: NodeId) { self.target = None; self.slices.clear(); - self.guides.clear(); self.objects.retain(|x| *x != id); self.anchors.remove(&id); if self.key == Some(id) { @@ -99,6 +98,15 @@ impl Selection { } } } + /// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a + /// Shift-click on a guide). + pub fn toggle_guide(&mut self, i: usize) { + if let Some(k) = self.guides.iter().position(|g| *g == i) { + self.guides.remove(k); + } else { + self.guides.push(i); + } + } pub fn toggle(&mut self, id: NodeId) { if self.contains(id) { self.remove(id) } else { self.add(id) } } @@ -117,7 +125,8 @@ impl Selection { self.target = None; } self.slices.retain(|id| doc.is_slice(*id)); - self.guides.retain(|i| *i < doc.guides.len()); + // Guides on a hidden or locked layer can't stay selected. + self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g))); } /// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected /// (the art of its sublayers too), anything else is selected itself. @@ -180,8 +189,16 @@ mod tests { s.set_guides([2, 0, 2]); assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0])); assert!(s.has_objects_or_guides() && s.is_empty()); + // Shift-adding an object keeps them; a new selection doesn't. s.add(NodeId(1)); + assert_eq!(s.guides, vec![2, 0]); + s.set([NodeId(1)]); assert!(s.guides.is_empty()); + // Toggled, a guide joins (or leaves) the selected objects. + s.toggle_guide(0); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0])); + s.toggle_guide(0); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![])); // Pruning drops guides the document no longer has. let mut d = Document::new(100.0, 100.0); d.guides.push(crate::Guide::new(true, 10.0)); @@ -190,6 +207,36 @@ mod tests { assert_eq!(s.guides, vec![0]); } + #[test] + fn guides_show_and_lock_with_their_layer() { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) }; + assert!(d.guide_shown(&g) && d.guide_editable(&g)); + d.node_mut(layer).unwrap().locked = true; + assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable"); + d.node_mut(layer).unwrap().visible = false; + assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden"); + // On no layer, or a layer that's gone: always there. + assert!(d.guide_shown(&crate::Guide::new(false, 5.0))); + let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) }; + assert!(d.guide_shown(&gone) && d.guide_editable(&gone)); + d.guides.push(g); + assert_eq!(d.guides_on(layer).count(), 1); + // Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer. + d.node_mut(layer).unwrap().visible = true; + d.node_mut(layer).unwrap().locked = false; + d.guides.push(crate::Guide::new(false, 5.0)); + d.guides.push(gone); + assert_eq!(d.adopt_guides(), 2); + assert!(d.guides.iter().all(|g| g.layer == Some(layer))); + assert_eq!(d.adopt_guides(), 0, "once"); + // A guide saved before guides had layers reads as one on no layer. + let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap(); + assert_eq!(old.layer, None); + assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written"); + } + #[test] fn selection_changes_clear_the_target() { let mut s = Selection::default(); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index aef35e23c..012e64b2b 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -95,6 +95,11 @@ pub struct CharStyle { /// their line line up with it. #[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")] pub char_align: CharAlign, + /// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the + /// font's own proportional widths, OpenType `palt` (#966). Vertical type sets them as before + /// for now. + #[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")] + pub proportional_metrics: bool, } /// Where a character smaller than the largest on its line lines up with it: on the Roman @@ -205,6 +210,7 @@ impl Default for CharStyle { stroke_dash: None, position: CharPosition::Normal, char_align: CharAlign::RomanBaseline, + proportional_metrics: false, small_caps: None, } } @@ -954,7 +960,49 @@ pub struct TextObject { pub cached_baselines: Vec<(Point, Point)>, } +/// Conversion from stored character attributes to document-space type controls. +/// Point size follows the transformed em-height axis; horizontal scale is the ratio of +/// the two transformed em axes. Rotation, reflection and shear remain in the affine. +#[derive(Clone, Copy, Debug)] +pub struct TextStyleScale { + pub points: f64, + pub horizontal: f64, +} + impl TextObject { + /// None for collapsed or unrepresentable transforms, whose type dimensions cannot + /// be edited by compensating the stored attributes. + pub fn style_scale(&self) -> Option { + let [a, b, c, d, e, f] = self.xf.as_coeffs(); + if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) { + return None; + } + let (sx, sy) = (a.hypot(b), c.hypot(d)); + let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy); + let horizontal = sx / sy; + if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 { + return None; + } + // Avoid a pure rotation turning otherwise identical fields into mixed values. + let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v }; + Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) }) + } + + /// Character attributes as presented by controls (stored runs stay local for layout + /// and rich-text clipboard operations). Explicit vertical character scale stays separate. + pub fn effective_char_style(&self, style: &CharStyle) -> Option { + let scale = self.style_scale()?; + let mut style = style.clone(); + style.size *= scale.points; + style.leading = style.leading.map(|v| v * scale.points); + style.baseline_shift *= scale.points; + style.h_scale *= scale.horizontal; + if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) { + return None; + } + Some(style) + } + pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { Self { vertical: false, @@ -1190,6 +1238,19 @@ impl TextObject { mod tests { use super::*; + #[test] + fn character_controls_reject_collapsed_and_nonfinite_transforms() { + let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); + for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] { + t.xf = Affine::new(matrix); + assert!(t.effective_char_style(&t.first_style()).is_none()); + } + t.xf = Affine::rotate(0.83); + assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0); + t.xf = Affine::scale(1e308); + assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size"); + } + #[test] fn point_text_basics() { let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default()); diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index aad25ac93..49b8e297f 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] vectorcraft-geom = { workspace = true } +vectorcraft-three-d = { workspace = true } vectorcraft-doc = { workspace = true } vectorcraft-pathops = { workspace = true } vectorcraft-text = { workspace = true } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index f1a08bee9..148f2c018 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -39,7 +39,8 @@ fn clear_item_effects(item: &mut AppearanceItem) { /// Does anything in `n`'s subtree need baking? pub fn needs_bake(n: &Node) -> bool { - has_adjustment(n) + crate::has_revolve(n) + || has_adjustment(n) || has_crop_marks(n) || has_pathfinder(n) || has_own_paint(n) @@ -92,6 +93,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if let Some(m) = crop_marks_art(n) { return Some(bake_pieces(d, m)); } + if let Some(m) = crate::revolve_art(n) { + let m = evaluate_container(&m).unwrap_or(m); + return Some(bake_pieces(d, m)); + } // Type, images, symbol instances and live objects: reshaped through their outlines. if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) { let symbol = match &n.kind { @@ -226,6 +231,10 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, & /// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and /// effects are dropped. `None` for other kinds of objects. pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option { + if let Some(m) = crate::revolve::expand_revolve_art(n) { + let m = evaluate_container(&m).unwrap_or(m); + return Some(bake_pieces(d, m)); + } let (g, rule, ctx, mut m) = leaf_base(n)?; m.appearance.effects.retain(|e| e.visible); let mut pieces = vec![]; @@ -289,6 +298,9 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { /// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None` /// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing. pub fn bake_appearance(n: &Node) -> Option { + if let Some(m) = crate::revolve_art(n) { + return Some(m); + } if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) { return None; } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 1a32b427b..1c12a5c88 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -42,6 +42,7 @@ mod marks; pub mod pixel; mod raster; mod reshape; +mod revolve; pub mod stroke; mod stylize; mod util; @@ -68,6 +69,7 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RasterFx, outset, raster_effects}; pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape}; +pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use warp::{WarpStyle, warp_point}; /// Catalogue entry for one effect. @@ -122,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,7 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"]; const STYLIZE: &[&str] = &["Effect", "Stylize"]; const WARP: &[&str] = &["Effect", "Warp"]; const BLUR: &[&str] = &["Effect", "Blur"]; +const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"]; const DISTORT: &[&str] = &["Effect", "Distort"]; const SHARPEN: &[&str] = &["Effect", "Sharpen"]; const PIXELATE: &[&str] = &["Effect", "Pixelate"]; @@ -179,6 +188,13 @@ pub fn effect_catalog() -> Vec { let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false, lengths: lengths_of(id) }; let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true, lengths: lengths_of(id) }; let mut v = vec![ + g( + REVOLVE, + "Revolve…", + &["Effect", "3D and Materials"], + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}), + ), g( "convertToShape.rectangle", "Rectangle…", @@ -296,6 +312,62 @@ pub fn effect_catalog() -> Vec { "{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)", json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}), ), + r( + "brushStrokes.accentedEdges", + "Accented Edges…", + BRUSH_STROKES, + "{edgeWidth: pt 1..14 (2), edgeBrightness: 0..50 (38; high: white chalk, low: black ink), smoothness: 1..15 (5; softens the object first and leaves its faint edges out)} accentuates the object's edges", + json!({"edgeWidth": 2.0, "edgeBrightness": 38.0, "smoothness": 5.0}), + ), + r( + "brushStrokes.angledStrokes", + "Angled Strokes…", + BRUSH_STROKES, + "{directionBalance: 0..100 (50; 0: every stroke a left diagonal, 100: every stroke a right diagonal, between: light areas right, dark areas left), strokeLength: pt 3..50 (15), sharpness: 0..10 (3)} repaints the object with diagonal strokes, the light areas' going the opposite way to the dark areas'", + json!({"directionBalance": 50.0, "strokeLength": 15.0, "sharpness": 3.0}), + ), + r( + "brushStrokes.crosshatch", + "Crosshatch…", + BRUSH_STROKES, + "{strokeLength: pt 3..50 (9), sharpness: 0..20 (6), strength: 1..3 (1; passes of hatching)} keeps the object's detail under pencil hatching along both diagonals and roughens its edges", + json!({"strokeLength": 9.0, "sharpness": 6.0, "strength": 1.0}), + ), + r( + "brushStrokes.darkStrokes", + "Dark Strokes…", + BRUSH_STROKES, + "{balance: 0..10 (5; the higher, the more of the object counts as dark), blackIntensity: 0..10 (6), whiteIntensity: 0..10 (2)} paints the dark areas closer to black with short strokes and the light areas with long white strokes", + json!({"balance": 5.0, "blackIntensity": 6.0, "whiteIntensity": 2.0}), + ), + r( + "brushStrokes.inkOutlines", + "Ink Outlines…", + BRUSH_STROKES, + "{strokeLength: pt 1..50 (4), darkIntensity: 0..50 (20), lightIntensity: 0..50 (10)} redraws the object in pen and ink: fine outlines over its edges and ink strokes in its shadows", + json!({"strokeLength": 4.0, "darkIntensity": 20.0, "lightIntensity": 10.0}), + ), + r( + "brushStrokes.spatter", + "Spatter…", + BRUSH_STROKES, + "{sprayRadius: pt 0..25 (10; colours scattered up to 0.4 × this), smoothness: 1..15 (5; larger, smoother grains)} renders the object as if sprayed with a spatter airbrush", + json!({"sprayRadius": 10.0, "smoothness": 5.0}), + ), + r( + "brushStrokes.sprayedStrokes", + "Sprayed Strokes…", + BRUSH_STROKES, + "{strokeLength: pt 0..20 (12), sprayRadius: pt 0..25 (7; strokes scattered up to 0.3 × this), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} repaints the object with sprayed, angled strokes of its main colours", + json!({"strokeLength": 12.0, "sprayRadius": 7.0, "strokeDirection": "rightDiagonal"}), + ), + r( + "brushStrokes.sumiE", + "Sumi-e…", + BRUSH_STROKES, + "{strokeWidth: pt 3..15 (10), strokePressure: 0..15 (2; more ink), contrast: 0..40 (16)} paints the object in the Japanese style, as if with a wet brush full of black ink on rice paper: soft, blurred edges and rich blacks, the colours keeping their hue and the object its shape", + json!({"strokeWidth": 10.0, "strokePressure": 2.0, "contrast": 16.0}), + ), r( "distort.diffuseGlow", "Diffuse Glow…", @@ -589,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()) } diff --git a/crates/effects/src/pixel/brushstrokes.rs b/crates/effects/src/pixel/brushstrokes.rs new file mode 100644 index 000000000..416a67de4 --- /dev/null +++ b/crates/effects/src/pixel/brushstrokes.rs @@ -0,0 +1,518 @@ +//! Effect › Brush Strokes (Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, +//! Spatter, Sprayed Strokes, Sumi-e). +//! +//! The stroke filters repaint the object with brush strokes: each pixel takes the object's colour +//! averaged along a segment through it ([`Smear`], a bounded number of samples whatever the +//! length). Strokes lie in rows across their direction, each row starting at random and each +//! stroke shifted along itself at random, so they begin and end like separate strokes; a streaked +//! noise along them gives them their bristle texture. Accented Edges and Ink Outlines find edges +//! with Glowing Edges' Sobel operator over the softened object; Spatter and Sprayed Strokes shift +//! the content by noise as Ocean Ripple does. Every pattern lies in document space around the +//! object's centre, as the Texture filters' do. + +use std::f64::consts::{FRAC_1_SQRT_2, TAU}; + +use vectorcraft_geom::{Point, Vec2}; + +use super::distort::displace; +use super::edges::sobel; +use super::pixelate::{Place, cell, rand, value_noise}; +use super::texture::{Tones, luma, premultiply, smooth_noise, straight}; +use super::{PixelSpace, blur_plane, gaussian, pick, to16}; + +const SALT_ANGLED: u64 = 144; +const SALT_CROSS: u64 = 152; +const SALT_DARK: u64 = 160; +const SALT_INK: u64 = 168; +const SALT_SPATTER: u64 = 176; +const SALT_SPRAYED: u64 = 184; +const SALT_SUMI: u64 = 192; + +/// Sprayed Strokes' stroke directions, in its Stroke Direction menu's order: (label, parameter +/// value). +pub const STROKE_DIRECTIONS: [(&str, &str); 4] = + [("Right Diagonal", "rightDiagonal"), ("Horizontal", "horizontal"), ("Left Diagonal", "leftDiagonal"), ("Vertical", "vertical")]; + +/// The way Sprayed Strokes are painted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StrokeDirection { + RightDiagonal, + Horizontal, + LeftDiagonal, + Vertical, +} + +impl StrokeDirection { + const ALL: [StrokeDirection; 4] = + [StrokeDirection::RightDiagonal, StrokeDirection::Horizontal, StrokeDirection::LeftDiagonal, StrokeDirection::Vertical]; + + /// The direction of `value` (a [`STROKE_DIRECTIONS`] value, any case); Right Diagonal + /// otherwise. + pub fn parse(value: &str) -> Self { + pick(&STROKE_DIRECTIONS, &Self::ALL, value, StrokeDirection::RightDiagonal) + } + + /// The unit vector along the strokes (document axes, y down). + fn along(self) -> Vec2 { + match self { + StrokeDirection::RightDiagonal => RIGHT, + StrokeDirection::Horizontal => Vec2::new(1.0, 0.0), + StrokeDirection::LeftDiagonal => LEFT, + StrokeDirection::Vertical => Vec2::new(0.0, 1.0), + } + } +} + +/// Along a right diagonal stroke, rising to the right (y down). +const RIGHT: Vec2 = Vec2::new(FRAC_1_SQRT_2, -FRAC_1_SQRT_2); +/// Along a left diagonal stroke, falling to the right. +const LEFT: Vec2 = Vec2::new(FRAC_1_SQRT_2, FRAC_1_SQRT_2); + +/// The most samples a stroke averages, whatever its length. +const MAX_SAMPLES: usize = 16; + +/// The share of its length by which a stroke is shifted along itself at random, at most. +const JITTER: f64 = 0.25; + +/// How soft (points, the σ) the tones are that decide which strokes a pixel gets. +const TONE_SOFTNESS: f64 = 1.0; + +/// Dark Strokes' short dark strokes and long light ones (points). +pub(super) const DARK_LENGTH: f64 = 3.0; +pub(super) const LIGHT_LENGTH: f64 = 9.0; + +/// How soft (points, the σ) Ink Outlines' outlines are. +const INK_SOFTNESS: f64 = 0.6; + +/// A pixel at 72 ppi (points): how far the neighbours of the Sobel operator and of sampling +/// between pixels add to a filter's reach. +const MARGIN: f64 = 1.0; + +/// How far (document units) strokes `len` long reach: half their length, their random shift, the +/// softening between their samples and the pixels a sample is taken between. +pub(super) fn stroke_reach(len: f64) -> f64 { + let len = if len.is_finite() { len.max(0.0) } else { 0.0 }; + (0.5 + JITTER + 1.5 / (MAX_SAMPLES - 1) as f64) * len + MARGIN +} + +/// How far (document units) the tones reach that the stroke filters choose their strokes by. +pub(super) const TONE_REACH: f64 = 3.0 * TONE_SOFTNESS + MARGIN; + +/// How far Sumi-e reaches with strokes `width` across: the strokes, and the wet ink bleeding from +/// 3 σ of 0.2 × the width. +pub(super) fn sumi_reach(width: f64) -> f64 { + stroke_reach(width).max(0.6 * width + MARGIN) +} + +/// The σ (points) Accented Edges softens the object by before finding its edges, for Edge Width +/// `width` (1..14) and Smoothness `smoothness` (1..15). +pub(super) fn accent_softness(width: f64, smoothness: f64) -> f64 { + 0.35 * width.clamp(1.0, 14.0) + 0.1 * smoothness.clamp(1.0, 15.0) +} + +/// How far Accented Edges reaches for Edge Width `width` and Smoothness `smoothness`. +pub(super) fn accent_reach(width: f64, smoothness: f64) -> f64 { + 3.0 * accent_softness(width, smoothness) + MARGIN +} + +/// How far Ink Outlines reaches with strokes `len` long. +pub(super) fn ink_reach(len: f64) -> f64 { + stroke_reach(len).max(3.0 * INK_SOFTNESS + MARGIN) +} + +/// How far (points) Crosshatch roughens the edges, with strokes `len` long. +pub(super) fn rough(len: f64) -> f64 { + 0.1 * if len.is_finite() { len.max(0.0) } else { 0.0 } +} + +/// The longest shift (points) Spatter gives at Spray Radius `radius` (0..25). +pub(super) fn spatter_shift(radius: f64) -> f64 { + 0.4 * radius.clamp(0.0, 25.0) +} + +/// The longest shift (points) Sprayed Strokes' spray gives at Spray Radius `radius` (0..25). +pub(super) fn spray_shift(radius: f64) -> f64 { + 0.3 * radius.clamp(0.0, 25.0) +} + +/// The object's colours averaged along strokes `len` long: [`MAX_SAMPLES`] at most, the object +/// softened first when they lie more than a pixel apart. +struct Smear<'a> { + /// Premultiplied colours (0..1). + src: Vec<[f32; 4]>, + w: usize, + h: usize, + place: &'a Place, + len: f64, + n: usize, +} + +impl<'a> Smear<'a> { + fn new(px: &[[u8; 4]], w: usize, h: usize, place: &'a Place, len: f64) -> Self { + let len = if len.is_finite() { len.max(0.0) } else { 0.0 }; + let span = len / place.px; + let n = if span.is_finite() { (span.ceil() as usize).saturating_add(1).clamp(1, MAX_SAMPLES) } else { MAX_SAMPLES }; + let gap = if n > 1 { span / (n - 1) as f64 } else { 0.0 }; + let mut wide = to16(px); + if gap > 1.0 { + gaussian(&mut wide, w, 0.5 * gap); + } + let src = wide.iter().map(|p| p.map(|v| f32::from(v) / 65535.0)).collect(); + Self { src, w, h, place, len, n } + } + + /// The premultiplied colour (0..1) averaged along the stroke through `centre` (relative to the + /// object's centre) along `dir`. + fn at(&self, centre: Point, dir: Vec2) -> [f32; 4] { + let half = dir * (0.5 * self.len); + let (a, b) = (self.place.to_px * (centre - half), self.place.to_px * (centre + half)); + if !(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) { + return [0.0; 4]; + } + // Pixel centres at whole coordinates. + let (mut x, mut y, step) = match self.n { + 0 | 1 => (0.5 * (a.x + b.x) - 0.5, 0.5 * (a.y + b.y) - 0.5, (0.0, 0.0)), + n => (a.x - 0.5, a.y - 0.5, ((b.x - a.x) / (n - 1) as f64, (b.y - a.y) / (n - 1) as f64)), + }; + let mut acc = [0.0f32; 4]; + for _ in 0..self.n { + self.add(&mut acc, x, y); + x += step.0; + y += step.1; + } + acc.map(|v| v / self.n.max(1) as f32) + } + + /// Add the colour at (`x`, `y`) (pixel centres at whole coordinates), interpolated between the + /// four pixels around it, to `acc`; transparent outside. + fn add(&self, acc: &mut [f32; 4], x: f64, y: f64) { + let (x0, y0) = (x.floor(), y.floor()); + if !(x0 >= -1.0 && y0 >= -1.0 && x0 < self.w as f64 && y0 < self.h as f64) { + return; + } + let (fx, fy) = ((x - x0) as f32, (y - y0) as f32); + let (i, j) = (x0 as isize, y0 as isize); + // Inside the raster: the four pixels as two pairs. + if i >= 0 && j >= 0 && (i as usize) + 1 < self.w && (j as usize) + 1 < self.h { + let at = j as usize * self.w + i as usize; + if let (Some([p00, p10]), Some([p01, p11])) = (self.src.get(at..at + 2), self.src.get(at + self.w..at + self.w + 2)) { + let (k00, k10, k01, k11) = ((1.0 - fx) * (1.0 - fy), fx * (1.0 - fy), (1.0 - fx) * fy, fx * fy); + for (c, a) in acc.iter_mut().enumerate() { + *a += p00[c] * k00 + p10[c] * k10 + p01[c] * k01 + p11[c] * k11; + } + } + return; + } + for (row, wy) in [(j, 1.0 - fy), (j + 1, fy)] { + let Some(row) = usize::try_from(row).ok().filter(|r| *r < self.h) else { continue }; + for (col, wx) in [(i, 1.0 - fx), (i + 1, fx)] { + let Some(col) = usize::try_from(col).ok().filter(|c| *c < self.w) else { continue }; + if let Some(p) = self.src.get(row * self.w + col) { + let k = wx * wy; + for (a, v) in acc.iter_mut().zip(p) { + *a += v * k; + } + } + } + } + } + + /// The stroke of pattern `salt` through `q` along `dir`, its rows `width` apart. + fn stroke(&self, salt: u64, q: Point, dir: Vec2, width: f64) -> [f32; 4] { + self.at(q + dir * stroke_shift(salt, q, dir, self.len, width), dir) + } +} + +/// `q`'s coordinates along `dir` (unit) and across it. +fn frame(q: Point, dir: Vec2) -> (f64, f64) { + (q.x * dir.x + q.y * dir.y, q.y * dir.x - q.x * dir.y) +} + +/// How far along `dir` the stroke through `q` is shifted: strokes `len` long lie in rows `width` +/// apart across `dir`, each row starting at random, each stroke shifted by up to [`JITTER`] × +/// `len`. +fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 { + if !(len > 0.0 && width > 0.0) { + return 0.0; + } + let (u, v) = frame(q, dir); + let Some(row) = cell(v / width) else { return 0.0 }; + let Some(col) = cell(u / len + rand(salt, row, 0, 0)) else { return 0.0 }; + (2.0 * rand(salt, col, row, 1) - 1.0) * JITTER * len +} + +/// Smooth noise in [-1, 1) streaked along `dir`: blobs `along` long and `across` wide. +fn streak(salt: u64, q: Point, dir: Vec2, along: f64, across: f64) -> f64 { + let (u, v) = frame(q, dir); + 2.0 * value_noise(salt, u / along.max(1e-3), v / across.max(1e-3)) - 1.0 +} + +/// The straight colour and coverage of premultiplied `pm` (0..1). +fn unpremultiply(pm: [f32; 4]) -> ([f32; 3], f32) { + let a = pm[3].clamp(0.0, 1.0); + if a > 1e-6 { ([pm[0], pm[1], pm[2]].map(|v| (v / a).clamp(0.0, 1.0)), a) } else { ([0.0; 3], 0.0) } +} + +/// `a` + (`b` − `a`) × `t`, channel by channel. +fn mix(a: [f32; N], b: [f32; N], t: f32) -> [f32; N] { + std::array::from_fn(|k| a[k] + (b[k] - a[k]) * t) +} + +/// A smooth step from 0 at `t` ≤ 0 to 1 at `t` ≥ 1. +fn ramp(t: f64) -> f64 { + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + t * t * (3.0 - 2.0 * t) +} + +/// Pixel `p` (premultiplied 0..255) as premultiplied 0..1. +fn unit(p: [u8; 4]) -> [f32; 4] { + p.map(|v| f32::from(v) / 255.0) +} + +/// The object's edges after softening it by `sigma` (document units): about 1 across a step from +/// black to white or from clear to opaque, the same at any resolution. Empty when the sizes don't +/// match. +fn edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec { + let mut tone: Vec = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect(); + let mut cover: Vec = px.iter().map(|p| f32::from(p[3]) / 255.0).collect(); + let s = sigma / place.px; + blur_plane(&mut tone, w, h, s); + blur_plane(&mut cover, w, h, s); + // A step softened by σ pixels rises by 1 / (σ √(2π)) per pixel at most, and Sobel gives 8 × + // the slope; a pixel is the least it softens by. + let norm = (s.max(0.8) * TAU.sqrt() / 8.0) as f32; + let norm = if norm.is_finite() { norm } else { 0.0 }; + sobel(&tone, w, h).iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c) * norm).collect() +} + +/// Accented Edges: the object's edges accented in white chalk when `brightness` (0..50) is high, +/// in black ink when it's low, about `width` (1..14) wide; `smoothness` (1..15) softens the object +/// first and leaves out its fainter edges. +pub(super) fn accented_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, brightness: f64, smoothness: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + let found = edges(px, w, h, &place, accent_softness(width, smoothness)); + let faint = (0.02 * smoothness.clamp(1.0, 15.0)) as f32; + let level = (brightness / 50.0).clamp(0.0, 1.0) as f32; + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let e = found.get(y * w + x).copied().unwrap_or(0.0); + let e = (2.5 * (e - faint) / (1.0 - faint)).clamp(0.0, 1.0); + let c = straight(*p); + // Chalk or ink of the brightness level, a little of the colour showing through. + let accent = c.map(|v| 0.9 * level + 0.1 * v); + *p = premultiply(mix(c, accent, e), f32::from(p[3]) / 255.0); + }); +} + +/// Angled Strokes: the object repainted in diagonal strokes `len` long, light areas in right +/// diagonal strokes and dark areas in left diagonal ones; `balance` (0..100) moves the split +/// (0 all left diagonal, 100 all right diagonal) and `sharpness` (0..10) brings the detail and +/// the bristles out. +pub(super) fn angled_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, len: f64, sharpness: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + let smear = Smear::new(px, w, h, &place, len); + let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px); + let split = 1.07 - 1.14 * (balance / 100.0).clamp(0.0, 1.0); + let sharp = sharpness.clamp(0.0, 10.0); + let (detail, bristle) = ((0.05 * sharp) as f32, 0.03 + 0.012 * sharp); + let row = (0.2 * smear.len).max(1.0); + let src = px.to_vec(); + place.each(px, |x, y, p| { + let q = place.at(x, y); + // The split between the directions wanders along the strokes, so they interlock. + let right = tones.get(x, y) > split + 0.06 * streak(SALT_ANGLED + 3, q, RIGHT, row, row); + let (dir, salt) = if right { (RIGHT, SALT_ANGLED) } else { (LEFT, SALT_ANGLED + 1) }; + let pm = smear.stroke(salt, q, dir, row); + let pm = mix(pm, unit(src.get(y * w + x).copied().unwrap_or([0; 4])), detail); + let (c, a) = unpremultiply(pm); + let n = (bristle * streak(SALT_ANGLED + 2, q, dir, 0.5 * smear.len.max(1.0), 0.6)) as f32; + *p = premultiply(c.map(|v| v + n), a); + }); +} + +/// Crosshatch: the object's detail kept under pencil hatching along both diagonals, strokes `len` +/// long, roughening its edges; `sharpness` (0..20) sets how crisp and dark the hatching is, +/// `passes` (1..3) how many layers of it there are. +pub(super) fn crosshatch(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, sharpness: f64, passes: u32) { + let Some(place) = Place::new(space, w, h) else { return }; + let smear = Smear::new(px, w, h, &place, len); + let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px); + let sharp = sharpness.clamp(0.0, 20.0); + let (amount, crisp) = (0.05 + 0.01 * sharp, 1.0 + 0.3 * sharp); + let row = (0.2 * smear.len).max(1.0); + let roughen = rough(smear.len); + let src = px.to_vec(); + place.each(px, |x, y, p| { + let q = place.at(x, y); + let line = |k: u32| { + let dir = if k.is_multiple_of(2) { RIGHT } else { LEFT }; + streak(SALT_CROSS + u64::from(k), q, dir, smear.len.max(1.0), 0.7 + 0.3 * f64::from(k)) + }; + let first = line(0); + // The edges roughened along the first hatching. + let o = q + RIGHT * (roughen * first); + let strokes = mix(smear.stroke(SALT_CROSS + 4, o, RIGHT, row), smear.stroke(SALT_CROSS + 5, o, LEFT, row), 0.5); + let pm = mix(unit(src.get(y * w + x).copied().unwrap_or([0; 4])), strokes, 0.5); + let (mut c, a) = unpremultiply(pm); + if a <= 0.0 { + *p = [0; 4]; + return; + } + let shade = 1.3 - tones.get(x, y); + for k in 0..passes.clamp(1, 3) { + let v = (if k == 0 { first } else { line(k) } * crisp).clamp(-1.0, 1.0); + // Dark pencil lines and the light paper between them. + let s = if v > 0.0 { -(amount * v * shade) } else { -0.6 * amount * v }; + let s = s.clamp(-1.0, 1.0) as f32; + c = c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s }); + } + *p = premultiply(c, a); + }); +} + +/// Dark Strokes: dark areas painted closer to black with short strokes, light areas with long +/// strokes closer to white; `balance` (0..10) moves the tone between them up, `black` (0..10) and +/// `white` (0..10) set how dark and how light they get. +pub(super) fn dark_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, black: f64, white: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + let short = Smear::new(px, w, h, &place, DARK_LENGTH); + let long = Smear::new(px, w, h, &place, LIGHT_LENGTH); + let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px); + let split = 0.15 + 0.07 * balance.clamp(0.0, 10.0); + let (black, white) = (0.085 * black.clamp(0.0, 10.0), 0.085 * white.clamp(0.0, 10.0)); + place.each(px, |x, y, p| { + let q = place.at(x, y); + let dark = 1.0 - ramp((tones.get(x, y) - split) / 0.12 + 0.5); + let painted = |light: bool| { + let (smear, dir, salt) = if light { (&long, RIGHT, SALT_DARK) } else { (&short, LEFT, SALT_DARK + 1) }; + let (c, a) = unpremultiply(smear.stroke(salt, q, dir, 1.0)); + let n = 0.7 + 0.3 * streak(salt + 2, q, dir, 0.5 * smear.len, 0.5); + let c = if light { + let s = (white * n).clamp(0.0, 1.0) as f32; + c.map(|v| v + (1.0 - v) * s) + } else { + let s = (black * n).clamp(0.0, 1.0) as f32; + c.map(|v| v * (1.0 - s)) + }; + [c[0] * a, c[1] * a, c[2] * a, a] + }; + let pm = if dark >= 1.0 { + painted(false) + } else if dark <= 0.0 { + painted(true) + } else { + mix(painted(true), painted(false), dark as f32) + }; + let (c, a) = unpremultiply(pm); + *p = premultiply(c, a); + }); +} + +/// Ink Outlines: the object redrawn in pen and ink, fine outlines over its edges and ink strokes +/// `len` long in its shadows; `dark` (0..50) darkens the shadows, `light` (0..50) lightens the +/// highlights. +pub(super) fn ink_outlines(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, dark: f64, light: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + let smear = Smear::new(px, w, h, &place, len); + let found = edges(px, w, h, &place, INK_SOFTNESS); + let (dark, light) = ((dark / 50.0).clamp(0.0, 1.0), (light / 50.0).clamp(0.0, 1.0)); + let row = (0.25 * smear.len).max(0.75); + let src = px.to_vec(); + place.each(px, |x, y, p| { + let q = place.at(x, y); + let pm = mix(unit(src.get(y * w + x).copied().unwrap_or([0; 4])), smear.stroke(SALT_INK, q, LEFT, row), 0.5); + let (c, a) = unpremultiply(pm); + if a <= 0.0 { + *p = [0; 4]; + return; + } + let l = f64::from(luma(c)); + let (shadow, highlight) = (((0.75 - l) / 0.75).clamp(0.0, 1.0), ((l - 0.45) / 0.55).clamp(0.0, 1.0)); + let (down, up) = ((0.7 * dark * shadow) as f32, (0.7 * light * highlight) as f32); + let c = c.map(|v| v * (1.0 - down)).map(|v| v + (1.0 - v) * up); + // Ink lines along the strokes in the shadows, and the outlines. + let hatch = ramp(2.0 * streak(SALT_INK + 1, q, LEFT, 1.5 * smear.len.max(1.0), 0.5) + 0.2) * shadow * (0.4 + 1.2 * dark); + let outline = (1.5 * f64::from(found.get(y * w + x).copied().unwrap_or(0.0)) - 0.15).clamp(0.0, 1.0); + let ink = (0.9 * outline).max(0.8 * hatch).min(1.0) as f32; + *p = premultiply(c.map(|v| v * (1.0 - ink)), a); + }); +} + +/// Spatter: the object as if sprayed with a spatter airbrush, each pixel taking the colour of one +/// up to [`spatter_shift`] away; `smoothness` (1..15) makes the spatter's grains larger and +/// smoother. +pub(super) fn spatter(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, radius: f64, smoothness: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + let grain = 0.3 + 0.15 * smoothness.clamp(1.0, 15.0); + let most = spatter_shift(radius); + displace(px, w, h, &place, most, |q| { + let n = |salt: u64| 0.6 * smooth_noise(salt, q, grain) + 0.4 * smooth_noise(salt + 2, q, 0.4 * grain); + Vec2::new(n(SALT_SPATTER), n(SALT_SPATTER + 1)) * (1.8 * most) + }); +} + +/// Sprayed Strokes: the object repainted in sprayed strokes `len` long along `dir`, scattered by +/// up to [`spray_shift`]. +pub(super) fn sprayed_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, radius: f64, dir: StrokeDirection) { + let Some(place) = Place::new(space, w, h) else { return }; + let smear = Smear::new(px, w, h, &place, len); + let (dir, spray) = (dir.along(), spray_shift(radius)); + let along = (0.5 * smear.len).max(0.5); + place.each(px, |x, y, p| { + let q = place.at(x, y); + let n = |salt: u64| streak(salt, q, dir, along, 0.5); + let mut d = Vec2::new(n(SALT_SPRAYED), n(SALT_SPRAYED + 1)) * (1.6 * spray); + let l = d.hypot(); + if l > spray && l.is_finite() { + d *= spray / l; + } + let (c, a) = unpremultiply(smear.stroke(SALT_SPRAYED + 2, q + d, dir, (0.2 * smear.len).max(1.0))); + *p = premultiply(c, a); + }); +} + +/// Sumi-e: the object as if painted with a wet brush on rice paper, in strokes `width` (3..15) +/// across with soft, bleeding edges and rich blacks; `pressure` (0..15) loads the brush with more +/// ink and `contrast` (0..40) sets the contrast. It works in the ink (the value): the colours keep +/// their hue and saturation, and the object keeps its shape. +pub(super) fn sumi_e(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, pressure: f64, contrast: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + let smear = Smear::new(px, w, h, &place, width); + // The ink around each pixel: the object's darkness by its coverage, softened. + let mut wet: Vec = px.iter().map(|p| (1.0 - luma(straight(*p))) * f32::from(p[3]) / 255.0).collect(); + blur_plane(&mut wet, w, h, 0.2 * smear.len / place.px); + let load = 1.0 + 0.06 * pressure.clamp(0.0, 15.0); + // How far the ink moves along an S curve: up to once at 20, twice at 40. + let gain = contrast.clamp(0.0, 40.0) / 20.0; + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let q = place.at(x, y); + let (c, a) = unpremultiply(smear.stroke(SALT_SUMI, q, LEFT, (0.3 * smear.len).max(1.0))); + // Where the stroke brings no paint, the pixel's own colour. + let c = if a > 0.0 { c } else { straight(*p) }; + let l = f64::from(luma(c)); + // The ink bleeds into the paper around the darks, along its fibres. + let bleed = 0.85 * f64::from(wet.get(y * w + x).copied().unwrap_or(0.0)); + let fibre = 1.0 + 0.1 * streak(SALT_SUMI + 1, q, Vec2::new(1.0, 0.0), 2.0, 0.35); + let mut ink = ((1.0 - l).max(bleed) * load * fibre).clamp(0.0, 1.0); + let mut left = gain; + while left > 0.0 { + ink += (ramp(ink) - ink) * left.min(1.0); + left -= 1.0; + } + let tone = (1.0 - ink).clamp(0.0, 1.0); + // Darker towards black, lighter towards the paper's white: the hue stays. + let out = if tone <= l { + let k = if l > 1e-3 { (tone / l) as f32 } else { 0.0 }; + c.map(|v| v * k) + } else { + let k = ((tone - l) / (1.0 - l).max(1e-3)).clamp(0.0, 1.0) as f32; + c.map(|v| v + (1.0 - v) * k) + }; + *p = premultiply(out, f32::from(p[3]) / 255.0); + }); +} diff --git a/crates/effects/src/pixel/distort.rs b/crates/effects/src/pixel/distort.rs index fcf94056f..1f4884ffd 100644 --- a/crates/effects/src/pixel/distort.rs +++ b/crates/effects/src/pixel/distort.rs @@ -159,7 +159,7 @@ pub(super) fn diffuse_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel /// Move the object's content by `shift(q)` (document units; `q` a pixel's centre relative to the /// object's centre, its length capped at `most`): each pixel takes the colour `shift` away from it, /// interpolated between pixels, transparent beyond the raster. -fn displace(px: &mut [[u8; 4]], w: usize, h: usize, place: &Place, most: f64, shift: impl Fn(Point) -> Vec2) { +pub(super) fn displace(px: &mut [[u8; 4]], w: usize, h: usize, place: &Place, most: f64, shift: impl Fn(Point) -> Vec2) { if !(most.is_finite() && most > 0.0) { return; } diff --git a/crates/effects/src/pixel/edges.rs b/crates/effects/src/pixel/edges.rs index 25812fff1..6cacd04c4 100644 --- a/crates/effects/src/pixel/edges.rs +++ b/crates/effects/src/pixel/edges.rs @@ -21,38 +21,20 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes if alpha > 0.0 { [f32::from(p[0]) / 255.0 / alpha, f32::from(p[1]) / 255.0 / alpha, f32::from(p[2]) / 255.0 / alpha] } else { [0.0; 3] }; (straight, alpha) }; - let signal = |x: isize, y: isize| { - let (rgb, alpha) = pixel(x, y); - (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha - }; - let mut edges = vec![0.0f32; px.len()]; + let signal: Vec = (0..px.len()) + .map(|i| { + let (rgb, alpha) = pixel((i % w) as isize, (i / w) as isize); + (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha + }) + .collect(); + let coverage: Vec = src.iter().map(|p| f32::from(p[3]) / 255.0).collect(); + let mut edges: Vec = sobel(&signal, w, h).iter().zip(sobel(&coverage, w, h)).map(|(s, a)| s.max(a).clamp(0.0, 1.0)).collect(); let mut colours: [Vec; 3] = std::array::from_fn(|_| vec![0.0; px.len()]); - for y in 0..h { - for x in 0..w { - let x = x as isize; - let y = y as isize; - let gx = -signal(x - 1, y - 1) + signal(x + 1, y - 1) - 2.0 * signal(x - 1, y) + 2.0 * signal(x + 1, y) - signal(x - 1, y + 1) - + signal(x + 1, y + 1); - let gy = -signal(x - 1, y - 1) - 2.0 * signal(x, y - 1) - signal(x + 1, y - 1) - + signal(x - 1, y + 1) - + 2.0 * signal(x, y + 1) - + signal(x + 1, y + 1); - let alpha_signal = |x, y| pixel(x, y).1; - let agx = -alpha_signal(x - 1, y - 1) + alpha_signal(x + 1, y - 1) - 2.0 * alpha_signal(x - 1, y) + 2.0 * alpha_signal(x + 1, y) - - alpha_signal(x - 1, y + 1) - + alpha_signal(x + 1, y + 1); - let agy = -alpha_signal(x - 1, y - 1) - 2.0 * alpha_signal(x, y - 1) - alpha_signal(x + 1, y - 1) - + alpha_signal(x - 1, y + 1) - + 2.0 * alpha_signal(x, y + 1) - + alpha_signal(x + 1, y + 1); - let index = y as usize * w + x as usize; - let edge = gx.hypot(gy).max(agx.hypot(agy)).clamp(0.0, 1.0); - if let Some(value) = edges.get_mut(index) { - *value = edge; - let (rgb, alpha) = pixel(x, y); - for channel in 0..3 { - colours[channel][index] = rgb[channel] * alpha * edge; - } + for (index, edge) in edges.iter().enumerate() { + let (rgb, alpha) = pixel((index % w) as isize, (index / w) as isize); + for (colour, v) in colours.iter_mut().zip(rgb) { + if let Some(c) = colour.get_mut(index) { + *c = v * alpha * edge; } } } @@ -85,3 +67,25 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes ]; } } + +/// The Sobel gradient's length over a `w` × `h` plane of values (zero beyond its edges): 8 × the +/// slope per pixel on a ramp, 4 × the step at a sharp edge. Empty when the sizes don't match. +pub(super) fn sobel(v: &[f32], w: usize, h: usize) -> Vec { + if w == 0 || w.checked_mul(h) != Some(v.len()) { + return Vec::new(); + } + let at = |x: usize, dx: isize, y: usize, dy: isize| -> f32 { + match (x.checked_add_signed(dx), y.checked_add_signed(dy)) { + (Some(x), Some(y)) if x < w && y < h => v.get(y * w + x).copied().unwrap_or(0.0), + _ => 0.0, + } + }; + (0..v.len()) + .map(|i| { + let (x, y) = (i % w, i / w); + let gx = at(x, 1, y, -1) - at(x, -1, y, -1) + 2.0 * (at(x, 1, y, 0) - at(x, -1, y, 0)) + at(x, 1, y, 1) - at(x, -1, y, 1); + let gy = at(x, -1, y, 1) - at(x, -1, y, -1) + 2.0 * (at(x, 0, y, 1) - at(x, 0, y, -1)) + at(x, 1, y, 1) - at(x, 1, y, -1); + gx.hypot(gy) + }) + .collect() +} diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index 62592768d..77cc0b620 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -1,8 +1,9 @@ -//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Distort › Diffuse -//! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, -//! Sharpen › Unsharp Mask, Stylize › Glowing -//! Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and -//! Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels. +//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes › +//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes +//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, +//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture +//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video › +//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels. //! //! - Every distance is in document units (points) and becomes pixels through the raster's //! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects @@ -12,6 +13,7 @@ //! - Beyond the raster's edges is transparency. mod blur; +mod brushstrokes; mod distort; mod edges; mod pixelate; @@ -24,14 +26,23 @@ use vectorcraft_geom::{Affine, Point, Rect}; use crate::util::{flag, num, text}; +pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection}; pub use distort::{GLASS_TEXTURES, GlassTexture}; pub use pixelate::{MEZZOTINT_TYPES, Mezzotint}; pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture}; /// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]). -pub const PIXEL_EFFECTS: [&str; 19] = [ +pub const PIXEL_EFFECTS: [&str; 27] = [ "blur.radial", "blur.smart", + "brushStrokes.accentedEdges", + "brushStrokes.angledStrokes", + "brushStrokes.crosshatch", + "brushStrokes.darkStrokes", + "brushStrokes.inkOutlines", + "brushStrokes.spatter", + "brushStrokes.sprayedStrokes", + "brushStrokes.sumiE", "distort.diffuseGlow", "distort.glass", "distort.oceanRipple", @@ -62,6 +73,32 @@ pub enum PixelFx { /// whose colour differs from its own by no more than `threshold` levels, over a grid of /// `samples` × `samples` neighbours at most. SmartBlur { radius: f64, threshold: f64, samples: u32 }, + /// Brush Strokes › Accented Edges: the edges, about `width` (1..14) wide, accented towards + /// white chalk or black ink as `brightness` (0..50) says; `smoothness` (1..15) softens them. + AccentedEdges { width: f64, brightness: f64, smoothness: f64 }, + /// Brush Strokes › Angled Strokes: diagonal strokes `length` long, the light areas' and the + /// dark areas' going opposite ways, split as `balance` (0..100) says, as crisp as `sharpness` + /// (0..10). + AngledStrokes { balance: f64, length: f64, sharpness: f64 }, + /// Brush Strokes › Crosshatch: pencil hatching `length` long along both diagonals, as crisp + /// as `sharpness` (0..20), in `strength` (1..3) passes. + Crosshatch { length: f64, sharpness: f64, strength: u32 }, + /// Brush Strokes › Dark Strokes: short strokes towards black in the dark areas, long ones + /// towards white in the light areas, split by `balance` (0..10), as dark as `black` (0..10) + /// and as light as `white` (0..10). + DarkStrokes { balance: f64, black: f64, white: f64 }, + /// Brush Strokes › Ink Outlines: pen-and-ink outlines and strokes `length` long, the shadows + /// darkened by `dark` (0..50) and the highlights lightened by `light` (0..50). + InkOutlines { length: f64, dark: f64, light: f64 }, + /// Brush Strokes › Spatter: a spatter airbrush spraying within `radius` (0..25), its grains as + /// smooth as `smoothness` (1..15). + Spatter { radius: f64, smoothness: f64 }, + /// Brush Strokes › Sprayed Strokes: sprayed strokes `length` long along `direction`, scattered + /// as `radius` (0..25) says. + SprayedStrokes { length: f64, radius: f64, direction: StrokeDirection }, + /// Brush Strokes › Sumi-e: wet black brush strokes `width` (3..15) across on rice paper, as + /// loaded as `pressure` (0..15) says, with `contrast` (0..40). + SumiE { width: f64, pressure: f64, contrast: f64 }, /// Distort › Diffuse Glow: the highlights glow white as strongly as `glow` (0..20), from the /// brightness `clear` (0..20) leaves clear, under white grain as dense as `graininess` (0..10). DiffuseGlow { graininess: f64, glow: f64, clear: f64 }, @@ -165,6 +202,44 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0), samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7), }, + "brushStrokes.accentedEdges" => PixelFx::AccentedEdges { + width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0), + brightness: num(p, "edgeBrightness", 38.0).clamp(0.0, 50.0), + smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0), + }, + "brushStrokes.angledStrokes" => PixelFx::AngledStrokes { + balance: num(p, "directionBalance", 50.0).clamp(0.0, 100.0), + length: num(p, "strokeLength", 15.0).clamp(3.0, 50.0), + sharpness: num(p, "sharpness", 3.0).clamp(0.0, 10.0), + }, + "brushStrokes.crosshatch" => PixelFx::Crosshatch { + length: num(p, "strokeLength", 9.0).clamp(3.0, 50.0), + sharpness: num(p, "sharpness", 6.0).clamp(0.0, 20.0), + strength: num(p, "strength", 1.0).clamp(1.0, 3.0).round() as u32, + }, + "brushStrokes.darkStrokes" => PixelFx::DarkStrokes { + balance: num(p, "balance", 5.0).clamp(0.0, 10.0), + black: num(p, "blackIntensity", 6.0).clamp(0.0, 10.0), + white: num(p, "whiteIntensity", 2.0).clamp(0.0, 10.0), + }, + "brushStrokes.inkOutlines" => PixelFx::InkOutlines { + length: num(p, "strokeLength", 4.0).clamp(1.0, 50.0), + dark: num(p, "darkIntensity", 20.0).clamp(0.0, 50.0), + light: num(p, "lightIntensity", 10.0).clamp(0.0, 50.0), + }, + "brushStrokes.spatter" => { + PixelFx::Spatter { radius: num(p, "sprayRadius", 10.0).clamp(0.0, 25.0), smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0) } + } + "brushStrokes.sprayedStrokes" => PixelFx::SprayedStrokes { + length: num(p, "strokeLength", 12.0).clamp(0.0, 20.0), + radius: num(p, "sprayRadius", 7.0).clamp(0.0, 25.0), + direction: StrokeDirection::parse(text(p, "strokeDirection", "rightDiagonal")), + }, + "brushStrokes.sumiE" => PixelFx::SumiE { + width: num(p, "strokeWidth", 10.0).clamp(3.0, 15.0), + pressure: num(p, "strokePressure", 2.0).clamp(0.0, 15.0), + contrast: num(p, "contrast", 16.0).clamp(0.0, 40.0), + }, "distort.diffuseGlow" => PixelFx::DiffuseGlow { graininess: num(p, "graininess", 6.0).clamp(0.0, 10.0), glow: num(p, "glowAmount", 10.0).clamp(0.0, 20.0), @@ -248,6 +323,15 @@ impl PixelFx { PixelFx::RadialBlur { zoom: false, .. } => (far - near).max(0.0), PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0), PixelFx::SmartBlur { radius, .. } => radius, + PixelFx::AccentedEdges { .. } => 0.0, + // Strokes reach out of the object as far as they reach into it. + PixelFx::AngledStrokes { length, .. } | PixelFx::InkOutlines { length, .. } => brushstrokes::stroke_reach(length), + PixelFx::Crosshatch { length, .. } => brushstrokes::stroke_reach(length) + brushstrokes::rough(length), + PixelFx::DarkStrokes { .. } => brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH), + PixelFx::Spatter { radius, .. } => brushstrokes::spatter_shift(radius), + PixelFx::SprayedStrokes { length, radius, .. } => brushstrokes::stroke_reach(length) + brushstrokes::spray_shift(radius), + // Sumi-e keeps the object's shape. + PixelFx::SumiE { .. } => 0.0, PixelFx::DiffuseGlow { .. } => 0.0, // Content shifted outwards reaches past the bounds by as much as the shift. PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion), @@ -275,6 +359,17 @@ impl PixelFx { match *self { PixelFx::RadialBlur { .. } => None, PixelFx::SmartBlur { radius, .. } => Some(radius), + PixelFx::AccentedEdges { width, smoothness, .. } => Some(brushstrokes::accent_reach(width, smoothness)), + // The strokes, and the softened tones that choose them. + PixelFx::AngledStrokes { length, .. } => Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH)), + PixelFx::Crosshatch { length, .. } => { + Some((brushstrokes::stroke_reach(length) + brushstrokes::rough(length)).max(brushstrokes::TONE_REACH)) + } + PixelFx::DarkStrokes { .. } => Some(brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH).max(brushstrokes::TONE_REACH)), + PixelFx::InkOutlines { length, .. } => Some(brushstrokes::ink_reach(length)), + PixelFx::Spatter { radius, .. } => Some(brushstrokes::spatter_shift(radius)), + PixelFx::SprayedStrokes { length, radius, .. } => Some(brushstrokes::stroke_reach(length) + brushstrokes::spray_shift(radius)), + PixelFx::SumiE { width, .. } => Some(brushstrokes::sumi_reach(width)), PixelFx::DiffuseGlow { .. } => Some(3.0 * distort::GLOW_SPREAD), PixelFx::Glass { distortion, .. } => Some(distort::glass_shift(distortion)), PixelFx::OceanRipple { size, magnitude } => Some(distort::ripple_shift(size, magnitude)), @@ -306,6 +401,14 @@ impl PixelFx { match *self { PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes), PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples), + PixelFx::AccentedEdges { width, brightness, smoothness } => brushstrokes::accented_edges(px, w, h, space, width, brightness, smoothness), + PixelFx::AngledStrokes { balance, length, sharpness } => brushstrokes::angled_strokes(px, w, h, space, balance, length, sharpness), + PixelFx::Crosshatch { length, sharpness, strength } => brushstrokes::crosshatch(px, w, h, space, length, sharpness, strength), + PixelFx::DarkStrokes { balance, black, white } => brushstrokes::dark_strokes(px, w, h, space, balance, black, white), + PixelFx::InkOutlines { length, dark, light } => brushstrokes::ink_outlines(px, w, h, space, length, dark, light), + PixelFx::Spatter { radius, smoothness } => brushstrokes::spatter(px, w, h, space, radius, smoothness), + PixelFx::SprayedStrokes { length, radius, direction } => brushstrokes::sprayed_strokes(px, w, h, space, length, radius, direction), + PixelFx::SumiE { width, pressure, contrast } => brushstrokes::sumi_e(px, w, h, space, width, pressure, contrast), PixelFx::DiffuseGlow { graininess, glow, clear } => distort::diffuse_glow(px, w, h, space, graininess, glow, clear), PixelFx::Glass { distortion, smoothness, texture, scaling, invert } => { distort::glass(px, w, h, space, distortion, smoothness, texture, scaling, invert) diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 2de43c0e4..f2b8a7843 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -276,6 +276,14 @@ fn pixel_timings() { ("blur.smart", json!({"quality": "low"})), ("blur.smart", json!({"quality": "high", "radius": 30})), ("sharpen.unsharpMask", json!({"radius": 20})), + ("brushStrokes.accentedEdges", json!({"edgeWidth": 14})), + ("brushStrokes.angledStrokes", json!({"strokeLength": 50})), + ("brushStrokes.crosshatch", json!({"strokeLength": 50, "strength": 3})), + ("brushStrokes.darkStrokes", json!({})), + ("brushStrokes.inkOutlines", json!({"strokeLength": 50})), + ("brushStrokes.spatter", json!({"sprayRadius": 25})), + ("brushStrokes.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25})), + ("brushStrokes.sumiE", json!({"strokeWidth": 15})), ("pixelate.colorHalftone", json!({"maxRadius": 4})), ("pixelate.crystallize", json!({"cellSize": 3})), ("pixelate.crystallize", json!({"cellSize": 300})), @@ -982,3 +990,322 @@ fn distort_patterns_follow_the_object_at_any_resolution() { assert_eq!(shifted, one, "{id}"); } } + +/// The Brush Strokes effect ids. +fn brush_strokes() -> impl Iterator { + PIXEL_EFFECTS.into_iter().filter(|id| id.starts_with("brushStrokes.")) +} + +#[test] +fn brush_strokes_params_take_defaults_and_stay_in_range() { + assert_eq!(fx("brushStrokes.accentedEdges", json!({})), PixelFx::AccentedEdges { width: 2.0, brightness: 38.0, smoothness: 5.0 }); + assert_eq!( + fx("brushStrokes.accentedEdges", json!({"edgeWidth": 99, "edgeBrightness": -1, "smoothness": "x"})), + PixelFx::AccentedEdges { width: 14.0, brightness: 0.0, smoothness: 5.0 } + ); + assert_eq!(fx("brushStrokes.angledStrokes", json!({})), PixelFx::AngledStrokes { balance: 50.0, length: 15.0, sharpness: 3.0 }); + assert_eq!( + fx("brushStrokes.angledStrokes", json!({"directionBalance": 1e308, "strokeLength": 0, "sharpness": 11})), + PixelFx::AngledStrokes { balance: 100.0, length: 3.0, sharpness: 10.0 } + ); + assert_eq!(fx("brushStrokes.crosshatch", json!({})), PixelFx::Crosshatch { length: 9.0, sharpness: 6.0, strength: 1 }); + assert_eq!( + fx("brushStrokes.crosshatch", json!({"strokeLength": 51, "sharpness": -1, "strength": 2.6})), + PixelFx::Crosshatch { length: 50.0, sharpness: 0.0, strength: 3 } + ); + assert_eq!(fx("brushStrokes.crosshatch", json!({"strength": -7})), PixelFx::Crosshatch { length: 9.0, sharpness: 6.0, strength: 1 }); + assert_eq!(fx("brushStrokes.darkStrokes", json!({})), PixelFx::DarkStrokes { balance: 5.0, black: 6.0, white: 2.0 }); + assert_eq!( + fx("brushStrokes.darkStrokes", json!({"balance": 11, "blackIntensity": -1, "whiteIntensity": 1e9})), + PixelFx::DarkStrokes { balance: 10.0, black: 0.0, white: 10.0 } + ); + assert_eq!(fx("brushStrokes.inkOutlines", json!({})), PixelFx::InkOutlines { length: 4.0, dark: 20.0, light: 10.0 }); + assert_eq!( + fx("brushStrokes.inkOutlines", json!({"strokeLength": 0, "darkIntensity": 51, "lightIntensity": -5})), + PixelFx::InkOutlines { length: 1.0, dark: 50.0, light: 0.0 } + ); + assert_eq!(fx("brushStrokes.spatter", json!({})), PixelFx::Spatter { radius: 10.0, smoothness: 5.0 }); + assert_eq!(fx("brushStrokes.spatter", json!({"sprayRadius": 26, "smoothness": 0})), PixelFx::Spatter { radius: 25.0, smoothness: 1.0 }); + assert_eq!( + fx("brushStrokes.sprayedStrokes", json!({})), + PixelFx::SprayedStrokes { length: 12.0, radius: 7.0, direction: StrokeDirection::RightDiagonal } + ); + assert_eq!( + fx("brushStrokes.sprayedStrokes", json!({"strokeLength": -1, "sprayRadius": 1e308, "strokeDirection": "VERTICAL"})), + PixelFx::SprayedStrokes { length: 0.0, radius: 25.0, direction: StrokeDirection::Vertical } + ); + assert_eq!(fx("brushStrokes.sprayedStrokes", json!({"strokeDirection": "diagonal"})), fx("brushStrokes.sprayedStrokes", json!({}))); + assert_eq!(fx("brushStrokes.sumiE", json!({})), PixelFx::SumiE { width: 10.0, pressure: 2.0, contrast: 16.0 }); + assert_eq!( + fx("brushStrokes.sumiE", json!({"strokeWidth": 2, "strokePressure": 16, "contrast": -1})), + PixelFx::SumiE { width: 3.0, pressure: 15.0, contrast: 0.0 } + ); + // Outsets: the strokes and the shifts reach out of the object as far as into it; Accented + // Edges and Sumi-e keep the object's shape. + let b = Rect::new(0.0, 0.0, 100.0, 50.0); + let reach = brushstrokes::stroke_reach; + assert!((reach(20.0) - (0.75 * 20.0 + 1.5 / 15.0 * 20.0 + 1.0)).abs() < 1e-9); + assert_eq!(fx("brushStrokes.accentedEdges", json!({})).outset(b), 0.0); + assert_eq!(fx("brushStrokes.accentedEdges", json!({})).reach(), Some(3.0 * (0.35 * 2.0 + 0.5) + 1.0)); + assert_eq!(fx("brushStrokes.angledStrokes", json!({})).outset(b), reach(15.0)); + assert_eq!(fx("brushStrokes.crosshatch", json!({})).outset(b), reach(9.0) + 0.9); + assert_eq!(fx("brushStrokes.darkStrokes", json!({})).outset(b), reach(9.0)); + assert_eq!(fx("brushStrokes.inkOutlines", json!({"strokeLength": 50})).outset(b), reach(50.0)); + let spatter = fx("brushStrokes.spatter", json!({})); + assert_eq!((spatter.outset(b), spatter.reach()), (4.0, Some(4.0))); + assert_eq!(fx("brushStrokes.spatter", json!({"sprayRadius": 0})).outset(b), 0.0); + assert_eq!(fx("brushStrokes.sprayedStrokes", json!({})).outset(b), reach(12.0) + 0.3 * 7.0); + assert_eq!(fx("brushStrokes.sumiE", json!({})).outset(b), 0.0); + for id in brush_strokes() { + let f = fx(id, json!({})); + assert!(f.reach().is_some_and(|r| r >= f.outset(b)), "{id}"); + } +} + +/// A light square in a darker ground with a ramp across it, over the whole raster: edges, +/// shadows and highlights for the strokes to work on. +fn scene(w: usize, h: usize) -> Vec { + image(w, h, |x, y| if (12..36).contains(&x) && (12..36).contains(&y) { [235, 220, 190, 255] } else { [(30 + x) as u8, 40, 90, 255] }) +} + +/// `src` (`w` × `h`) through effect `id` with parameters `p`. +fn run_fx(src: &[u8], w: usize, h: usize, id: &str, p: serde_json::Value) -> Vec { + let mut d = src.to_vec(); + fx(id, p).apply(&mut d, w, h, &space(w, h)); + d +} + +#[test] +fn brush_strokes_each_repaint_the_art_their_own_way() { + let (w, h) = (48, 48); + let src = scene(w, h); + let mut seen: Vec<(&str, Vec)> = vec![]; + for id in brush_strokes() { + let d = run_fx(&src, w, h, id, json!({})); + assert_ne!(d, src, "{id} changes the art"); + for (other, o) in &seen { + assert_ne!(&d, o, "{id} differs from {other}"); + } + seen.push((id, d)); + } + assert_eq!(seen.len(), 8); + // Each stroke direction paints its own way. + let mut ways: Vec> = vec![]; + for (_, way) in STROKE_DIRECTIONS { + let d = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"strokeDirection": way})); + assert!(!ways.contains(&d), "{way} differs from the other directions"); + ways.push(d); + } + // Every option shows. + for (id, a, b) in [ + ("brushStrokes.accentedEdges", json!({"edgeWidth": 1}), json!({"edgeWidth": 14})), + ("brushStrokes.accentedEdges", json!({"smoothness": 1}), json!({"smoothness": 15})), + ("brushStrokes.angledStrokes", json!({"directionBalance": 0}), json!({"directionBalance": 100})), + ("brushStrokes.angledStrokes", json!({"strokeLength": 3}), json!({"strokeLength": 50})), + ("brushStrokes.angledStrokes", json!({"sharpness": 0}), json!({"sharpness": 10})), + ("brushStrokes.crosshatch", json!({"strokeLength": 3}), json!({"strokeLength": 50})), + ("brushStrokes.crosshatch", json!({"sharpness": 0}), json!({"sharpness": 20})), + ("brushStrokes.crosshatch", json!({"strength": 1}), json!({"strength": 3})), + ("brushStrokes.darkStrokes", json!({"balance": 0}), json!({"balance": 10})), + ("brushStrokes.darkStrokes", json!({"blackIntensity": 0}), json!({"blackIntensity": 10})), + ("brushStrokes.darkStrokes", json!({"whiteIntensity": 0}), json!({"whiteIntensity": 10})), + ("brushStrokes.inkOutlines", json!({"strokeLength": 1}), json!({"strokeLength": 50})), + ("brushStrokes.inkOutlines", json!({"darkIntensity": 0}), json!({"darkIntensity": 50})), + ("brushStrokes.inkOutlines", json!({"lightIntensity": 0}), json!({"lightIntensity": 50})), + ("brushStrokes.spatter", json!({"sprayRadius": 5}), json!({"sprayRadius": 25})), + ("brushStrokes.spatter", json!({"smoothness": 1}), json!({"smoothness": 15})), + ("brushStrokes.sprayedStrokes", json!({"strokeLength": 0}), json!({"strokeLength": 20})), + ("brushStrokes.sprayedStrokes", json!({"sprayRadius": 0}), json!({"sprayRadius": 25})), + ("brushStrokes.sumiE", json!({"strokeWidth": 3}), json!({"strokeWidth": 15})), + ("brushStrokes.sumiE", json!({"strokePressure": 0}), json!({"strokePressure": 15})), + ("brushStrokes.sumiE", json!({"contrast": 0}), json!({"contrast": 40})), + ] { + assert_ne!(run_fx(&src, w, h, id, a.clone()), run_fx(&src, w, h, id, b.clone()), "{id}: {a} and {b}"); + } +} + +/// The mean of the colour channels over columns `xs` and rows `ys` (0..255). +fn mean_tone(d: &[u8], w: usize, xs: std::ops::Range, ys: std::ops::Range) -> f64 { + let px: Vec<[u8; 4]> = ys.flat_map(|y| xs.clone().map(move |x| (x, y))).map(|(x, y)| at(d, w, x, y)).collect(); + px.iter().map(|p| f64::from(p[0]) + f64::from(p[1]) + f64::from(p[2])).sum::() / (3.0 * px.len().max(1) as f64) +} + +/// Dark on the left, light on the right, opaque over the whole raster. +fn halves(w: usize, h: usize) -> Vec { + image(w, h, |x, _| if x < w / 2 { [50, 60, 100, 255] } else { [210, 200, 180, 255] }) +} + +#[test] +fn accented_edges_draw_chalk_or_ink_on_the_edges_only() { + let (w, h) = (48, 48); + // The halves inside a transparent margin. + let both = halves(w, h); + let src = image(w, h, |x, y| if (4..44).contains(&x) && (4..44).contains(&y) { at(&both, w, x, y) } else { [0; 4] }); + let chalk = run_fx(&src, w, h, "brushStrokes.accentedEdges", json!({"edgeBrightness": 50})); + let ink = run_fx(&src, w, h, "brushStrokes.accentedEdges", json!({"edgeBrightness": 0})); + let sum = |p: [u8; 4]| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]); + // Along the edge between the halves: white chalk on the dark side, black ink on the light. + assert!(sum(at(&chalk, w, 23, 24)) > sum(at(&src, w, 23, 24)) + 150, "{:?}", at(&chalk, w, 23, 24)); + assert!(sum(at(&ink, w, 24, 24)) + 150 < sum(at(&src, w, 24, 24)), "{:?}", at(&ink, w, 24, 24)); + // Away from the edges nothing changes, and the object keeps its shape. + for (x, y) in [(13, 24), (34, 24), (13, 13)] { + assert_eq!(at(&chalk, w, x, y), at(&src, w, x, y), "({x}, {y})"); + } + for (a, b) in chalk.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) { + assert_eq!(a[3], b[3]); + } +} + +#[test] +fn dark_and_ink_strokes_darken_the_shadows_and_sumi_e_inks_them_black() { + let (w, h) = (64, 48); + let src = halves(w, h); + let (shadow, light) = (8..24, 40..56); + let tone = |d: &[u8], xs: std::ops::Range| mean_tone(d, w, xs, 8..40); + let dark = run_fx(&src, w, h, "brushStrokes.darkStrokes", json!({})); + assert!(tone(&dark, shadow.clone()) < tone(&src, shadow.clone()) - 20.0, "dark strokes in the shadows"); + assert!(tone(&dark, light.clone()) > tone(&src, light.clone()) + 5.0, "white strokes in the light"); + let ink = run_fx(&src, w, h, "brushStrokes.inkOutlines", json!({})); + assert!(tone(&ink, shadow.clone()) < tone(&src, shadow.clone()) - 10.0, "ink in the shadows"); + // The outline along the edge is darker than the light side around it. + assert!(tone(&ink, 32..34) + 40.0 < tone(&ink, light.clone()), "an ink outline along the edge"); + let sumi = run_fx(&src, w, h, "brushStrokes.sumiE", json!({})); + assert!(tone(&sumi, shadow.clone()) < 0.4 * tone(&src, shadow.clone()), "rich blacks: {}", tone(&sumi, shadow.clone())); + assert!(tone(&sumi, 50..56) > 150.0, "the light stays light: {}", tone(&sumi, 50..56)); +} + +#[test] +fn sumi_e_keeps_the_shape_and_the_hues() { + let (w, h) = (48, 48); + // A strong yellow square and a dark blue one in a transparent margin. + let src = image(w, h, |x, y| match (x, y) { + (8..24, 8..40) => [230, 200, 40, 255], + (24..40, 8..40) => [30, 40, 120, 255], + _ => [0; 4], + }); + let d = run_fx(&src, w, h, "brushStrokes.sumiE", json!({"contrast": 40, "strokePressure": 15})); + // Transparency stays transparent and the coverage stays as it was: no fringe. + for (a, b) in d.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) { + assert_eq!(a[3], b[3]); + if b[3] == 0 { + assert_eq!(*a, [0; 4]); + } + } + // The yellow stays yellow, no more saturated than it was. + let sat = |p: [u8; 4]| { + let (hi, lo) = (p[..3].iter().max().copied().unwrap(), p[..3].iter().min().copied().unwrap()); + f64::from(hi - lo) / f64::from(hi.max(1)) + }; + let y = at(&d, w, 12, 24); + assert!(y[0] > y[2] + 80 && y[1] > y[2] + 80, "{y:?}"); + assert!(sat(y) <= sat([230, 200, 40, 255]) + 0.02, "{y:?}"); + // The dark blue is inked darker, still blue. + let b = at(&d, w, 34, 24); + assert!(b[2] < 120 && b[2] >= b[0] && b[2] >= b[1], "{b:?}"); +} + +#[test] +fn crosshatch_hatches_flat_colour_and_keeps_its_tone() { + let (w, h) = (48, 48); + let src = image(w, h, |_, _| grey(128)); + let spread = |d: &[u8]| { + let m = mean_tone(d, w, 8..40, 8..40); + let var = (8..40).flat_map(|x| (8..40).map(move |y| (x, y))).map(|(x, y)| (f64::from(at(d, w, x, y)[0]) - m).powi(2)).sum::() / 1024.0; + (m, var.sqrt()) + }; + let (one, three) = + (run_fx(&src, w, h, "brushStrokes.crosshatch", json!({})), run_fx(&src, w, h, "brushStrokes.crosshatch", json!({"strength": 3}))); + let ((m1, s1), (_, s3)) = (spread(&one), spread(&three)); + assert!((m1 - 128.0).abs() < 25.0 && s1 > 3.0, "{m1} ± {s1}"); + assert!(s3 > s1, "more passes, more hatching: {s3} vs {s1}"); +} + +#[test] +fn stroke_directions_smear_along_themselves() { + let (w, h) = (48, 48); + // A white line two pixels thick across black. + let src = image(w, h, |_, y| if (23..25).contains(&y) { grey(255) } else { grey(0) }); + let row = |d: &[u8], y: usize| mean_tone(d, w, 12..36, y..y + 1); + let along = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0, "strokeDirection": "horizontal"})); + let across = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0, "strokeDirection": "vertical"})); + assert!(row(&along, 20) < 1.0 && row(&along, 24) > 250.0, "strokes along the line keep it"); + assert!(row(&across, 20) > 15.0 && row(&across, 24) < 200.0, "strokes across it spread it: {} {}", row(&across, 20), row(&across, 24)); + // Diagonal strokes spread it too, along the diagonal. + let diagonal = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0})); + assert!(row(&diagonal, 20) > 5.0); +} + +#[test] +fn spatter_scatters_content_no_farther_than_its_radius() { + let (w, h) = (64, 32); + // The left half white, the right half transparent. + let src = image(w, h, |x, _| if x < 32 { grey(255) } else { [0; 4] }); + assert_eq!(run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 0})), src, "no radius, no spatter"); + let f = fx("brushStrokes.spatter", json!({"sprayRadius": 15})); + let most = f.reach().unwrap(); + let d = run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 15})); + assert_ne!(d, src); + let inside = |v: usize, n: usize| (v as f64 + 0.5).min(n as f64 - v as f64 - 0.5) > most + 1.0; + for y in (0..h).filter(|y| inside(*y, h)) { + for x in (0..w).filter(|x| inside(*x, w) && (*x as f64 + 0.5 - 32.0).abs() > most + 1.0) { + assert_eq!(at(&d, w, x, y), at(&src, w, x, y), "({x}, {y})"); + } + } + // The edge is spattered: white specks past it and holes before it. + let past = (33..36).flat_map(|x| (8..24).map(move |y| (x, y))).filter(|(x, y)| at(&d, w, *x, *y)[3] > 127).count(); + let holes = (28..31).flat_map(|x| (8..24).map(move |y| (x, y))).filter(|(x, y)| at(&d, w, *x, *y)[3] < 128).count(); + assert!(past > 0 && holes > 0, "{past} specks past the edge, {holes} holes before it"); +} + +#[test] +fn brush_strokes_look_no_farther_than_their_reach() { + let (w, h) = (200, 24); + let base = image(w, h, |x, y| [(x * 7 % 256) as u8, (y * 11 % 256) as u8, 120, 255]); + let cases = brush_strokes().map(|id| (id, json!({}))).chain([ + ("brushStrokes.accentedEdges", json!({"edgeWidth": 14, "smoothness": 15})), + ("brushStrokes.angledStrokes", json!({"strokeLength": 50})), + ("brushStrokes.crosshatch", json!({"strokeLength": 50, "strength": 3})), + ("brushStrokes.inkOutlines", json!({"strokeLength": 50})), + ("brushStrokes.spatter", json!({"sprayRadius": 25, "smoothness": 15})), + ("brushStrokes.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25, "strokeDirection": "horizontal"})), + ("brushStrokes.sumiE", json!({"strokeWidth": 15})), + ]); + for (id, p) in cases { + let reach = fx(id, p.clone()).reach().unwrap(); + // Everything from `cut` on differs; the pixels left of 20 can't tell. + let cut = 20 + reach.ceil() as usize + 1; + assert!(cut < w, "{id}"); + let other = image(w, h, |x, y| if x >= cut { [255 - (x * 5 % 256) as u8, 30, (y * 9 % 256) as u8, 200] } else { at(&base, w, x, y) }); + let (a, b) = (run_fx(&base, w, h, id, p.clone()), run_fx(&other, w, h, id, p.clone())); + for (x, y) in (0..20).flat_map(|x| (0..h).map(move |y| (x, y))) { + let (pa, pb) = (at(&a, w, x, y), at(&b, w, x, y)); + assert!(pa.iter().zip(pb).all(|(u, v)| u.abs_diff(v) <= 2), "{id} {p}: ({x}, {y}) {pa:?} vs {pb:?}, reach {reach}"); + } + } +} + +#[test] +fn brush_strokes_follow_the_object_at_any_resolution() { + let (w, h) = (48, 48); + let art = |x: f64, y: f64| -> [u8; 4] { + if (8.0..40.0).contains(&x) && (8.0..40.0).contains(&y) { [(x * 5.0) as u8, (y * 5.0) as u8, 120, 255] } else { [0; 4] } + }; + for id in brush_strokes() { + let f = fx(id, json!({})); + let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5)); + f.apply(&mut one, w, h, &space(w, h)); + let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0)); + let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) }; + f.apply(&mut two, 2 * w, 2 * h, &fine); + let down = image(w, h, |x, y| { + let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy)); + std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::() / 4) as u8) + }); + let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::() / one.len() as f64; + assert!(diff < 12.0, "{id}: {diff}"); + let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(31.0, 27.0), ..space(w, h) }; + let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5)); + f.apply(&mut shifted, w, h, &moved); + assert_eq!(shifted, one, "{id}"); + } +} diff --git a/crates/effects/src/pixel/texture.rs b/crates/effects/src/pixel/texture.rs index ead50a7f9..9855aa3cc 100644 --- a/crates/effects/src/pixel/texture.rs +++ b/crates/effects/src/pixel/texture.rs @@ -233,14 +233,14 @@ fn bevel(d: f64, width: f64) -> f64 { /// The object's straight luminance (0..1) softened by a Gaussian of `sigma` pixels, weighted by /// coverage so the transparency around it doesn't darken its edges. -struct Tones { +pub(super) struct Tones { v: Vec, w: usize, h: usize, } impl Tones { - fn new(px: &[[u8; 4]], w: usize, h: usize, sigma: f64) -> Self { + pub(super) fn new(px: &[[u8; 4]], w: usize, h: usize, sigma: f64) -> Self { let mut l: Vec = px.iter().map(|p| luma([p[0], p[1], p[2]].map(f32::from)) / 255.0).collect(); let mut a: Vec = px.iter().map(|p| f32::from(p[3]) / 255.0).collect(); blur_plane(&mut l, w, h, sigma); @@ -249,7 +249,7 @@ impl Tones { Self { v, w, h } } - fn get(&self, x: usize, y: usize) -> f64 { + pub(super) fn get(&self, x: usize, y: usize) -> f64 { let (x, y) = (x.min(self.w.saturating_sub(1)), y.min(self.h.saturating_sub(1))); self.v.get(y * self.w + x).map_or(0.0, |v| f64::from(*v)) } diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs new file mode 100644 index 000000000..2d5fa3eb8 --- /dev/null +++ b/crates/effects/src/revolve.rs @@ -0,0 +1,319 @@ +//! Revolve as live, shaded vector art, shared by the canvas and every exporter. +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer}; +use vectorcraft_geom::{PathData, Rect}; +use vectorcraft_three_d::{Edge, Revolve}; + +use crate::util::{flag, num, text}; +use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; + +pub const REVOLVE: &str = "threeD.revolve"; + +fn visible(fx: &[Effect]) -> bool { + fx.iter().any(|e| e.visible && e.id == REVOLVE) +} + +pub fn has_revolve(n: &Node) -> bool { + visible(&n.appearance.effects) || n.appearance.items.iter().any(|i| i.visible() && visible(i.effects())) +} + +/// Shared document-parameter conversion for rendering and interactive preview guides. +pub fn revolve_options(p: &Value) -> Revolve { + Revolve { + angle: num(p, "angle", 360.0), + offset: num(p, "offset", 0.0), + edge: if text(p, "edge", "left") == "right" { Edge::Right } else { Edge::Left }, + rotation_x: num(p, "rotationX", 0.0), + rotation_y: num(p, "rotationY", 0.0), + rotation_z: num(p, "rotationZ", 0.0), + perspective: num(p, "perspective", 0.0) / 100.0, + segments: num(p, "segments", 64.0).clamp(8.0, 128.0) as usize, + light_azimuth: num(p, "lightAzimuth", -45.0), + light_elevation: num(p, "lightElevation", 45.0), + light_intensity: num(p, "lightIntensity", 80.0) / 100.0, + ambient: num(p, "ambient", 25.0) / 100.0, + shade: flag(p, "shade", true), + } +} + +/// Validate before a command mutates the document. Loading external files is still bounded +/// by the evaluator; unsupported source kinds remain unchanged when rendered. +pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { + let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?; + let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; + if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) { + return Err("Revolve requires at least two anchors".into()); + } + if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) { + return Err("Revolve edge must be left or right".into()); + } + vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) +} + +fn profile(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, guide: false, clipping: false, .. } => Some(path.clone()), + NodeKind::Compound { children, .. } => { + Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect())) + } + _ => None, + } +} + +fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { + apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx) +} + +#[allow(clippy::too_many_arguments)] +fn surface( + n: &Node, + path: &PathData, + fx: &Effect, + paint: Paint, + opacity: f32, + blend: vectorcraft_doc::color::BlendMode, + expand: bool, +) -> Option { + let b = path.bounds()?; + // A loaded file or a profile edited past the limits keeps drawing its source path instead + // of crashing the frame. Commands validate the same evaluator and report its errors. + let faces = vectorcraft_three_d::revolve(path, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?; + // Visibility is an expansion option, never a change to live geometry or export baking. + // Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility + // failure also retains the complete surface rather than losing any of the user's art. + let visible = (expand + && flag(&fx.params, "expandVisibleOnly", true) + && paint.color().is_some() + && opacity == 1.0 + && blend == Default::default() + && n.has_default_transparency()) + .then(|| vectorcraft_three_d::visible_faces(&faces).ok()) + .flatten(); + let faces = visible.unwrap_or_else(|| { + faces.into_iter().map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth }).collect() + }); + let children = faces + .into_iter() + .map(|face| { + let mut fill = FillLayer::new(paint.clone()); + let mut items = vec![]; + if let Some(c) = paint.color() { + let [r, g, b] = c.to_rgb(); + let s = face.brightness as f32; + fill.paint = Paint::solid(Color::rgb(r * s, g * s, b * s)); + items.push(AppearanceItem::Fill(fill)); + } else { + items.push(AppearanceItem::Fill(fill)); + let mut shadow = FillLayer::new(Paint::solid(Color::BLACK)); + shadow.opacity = (1.0 - face.brightness) as f32; + items.push(AppearanceItem::Fill(shadow)); + } + // Independently antialiased adjacent polygons otherwise leave a hairline grid. + // Matching narrow edge paint overlaps their coverage in the canvas and exporters. + // Keep opacity on the surface group so these edges never accumulate transparency. + let items = items + .into_iter() + .flat_map(|item| { + let AppearanceItem::Fill(fill) = &item else { return vec![item] }; + let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75); + edge.opacity = fill.opacity; + vec![item, AppearanceItem::Stroke(edge)] + }) + .collect(); + Arc::new(Node::path(n.id, face.path, Appearance { items, ..Default::default() })) + }) + .collect(); + let mut group = Node::group(n.id, children); + group.opacity = opacity; + group.blend = blend; + Some(group) +} + +fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) { + match item { + AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend), + AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend), + } +} + +/// Evaluated art keeps the source's identity and transparency. Each item's opacity belongs +/// to its entire surface, not to each face, so overlapping faces don't accumulate alpha. +pub fn revolve_art(n: &Node) -> Option { + art(n, false) +} + +/// Revolve's own Expand Appearance path. The shared command and UI remain unchanged. +pub(crate) fn expand_revolve_art(n: &Node) -> Option { + art(n, true) +} + +fn art(n: &Node, expand: bool) -> Option { + if !has_revolve(n) { + return None; + } + let path = profile(n)?; + let ctx = GeomContext::of(n); + let mut children = vec![]; + let mut remaining = n.appearance.effects.clone(); + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + let fx = n.appearance.effects.get(index)?; + let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); + // An object's surface uses its top visible fill, falling back to its stroke. + let item = n + .appearance + .items + .iter() + .rev() + .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) + .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))?; + let (paint, opacity, blend) = paint_of(item); + let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); + children.push(Arc::new(surface(n, &path, fx, paint, opacity, blend, expand)?)); + // Later geometry acts on the projected art as a whole through container evaluation. + remaining = n + .appearance + .effects + .iter() + .enumerate() + .filter(|(i, e)| e.id != REVOLVE && (!is_geometry(&e.id) || *i > index)) + .map(|(_, e)| e.clone()) + .collect(); + } else { + let path = geometry(&path, &n.appearance.effects, &ctx); + remaining.retain(|e| !is_geometry(&e.id)); + for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { + if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) { + let fx = item.effects().get(index)?; + let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); + let (paint, opacity, blend) = paint_of(item); + let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); + let mut art = surface(n, &p, fx, paint, opacity, blend, expand)?; + art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); + children.push(Arc::new(art)); + } else { + children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() }))); + } + } + } + let mut art = n.clone(); + art.kind = NodeKind::Group { children, clip: false }; + art.appearance = Appearance { effects: remaining, ..Default::default() }; + Some(art) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use vectorcraft_doc::{Document, NodeId}; + use vectorcraft_geom::{Affine, BezPath}; + fn profile_node() -> Node { + let mut b = BezPath::new(); + b.move_to((100.0, 40.0)); + b.line_to((130.0, 60.0)); + b.line_to((130.0, 140.0)); + b.line_to((100.0, 160.0)); + Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0)) + } + fn fx() -> Effect { + crate::new_effect(REVOLVE, &json!({})).unwrap() + } + #[test] + fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() { + let mut n = profile_node(); + let before = n.path_data().unwrap().clone(); + n.appearance.effects.push(fx()); + let a = revolve_art(&n).unwrap(); + assert!(!a.children().unwrap().is_empty()); + assert_eq!(n.path_data().unwrap(), &before); + let b = a.visual_bounds().unwrap(); + n.transform(Affine::translate((80.0, 20.0)), false); + let moved = revolve_art(&n).unwrap().visual_bounds().unwrap(); + assert!((moved.x0 - b.x0 - 80.0).abs() < 1e-6 && (moved.y0 - b.y0 - 20.0).abs() < 1e-6); + n.appearance.effects[0].visible = false; + assert!(revolve_art(&n).is_none()); + } + #[test] + fn item_effect_and_transparency_are_scoped_to_whole_surface() { + let mut n = profile_node(); + n.opacity = 0.6; + n.appearance.items[0].effects_mut().push(fx()); + if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] { + f.opacity = 0.4; + } + let a = revolve_art(&n).unwrap(); + assert_eq!(a.opacity, 0.6); + let surface = &a.children().unwrap()[0]; + assert_eq!(surface.opacity, 0.4); + assert!(surface.children().unwrap().iter().all(|c| c.opacity == 1.0 && !has_revolve(c))); + } + #[test] + fn export_bakes_faces_with_unique_ids_and_no_revolve_effects() { + let mut d = Document::new(300.0, 300.0); + let mut n = profile_node(); + n.id = d.alloc_id(); + n.appearance.effects.push(fx()); + let mut stroke = |_: &mut Document, _: &PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None; + let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap(); + let mut ids = std::collections::HashSet::new(); + fn visit(n: &Node, ids: &mut std::collections::HashSet) { + assert!(ids.insert(n.id)); + assert!(!has_revolve(n)); + for c in n.children().into_iter().flatten() { + visit(c, ids); + } + } + visit(&art, &mut ids); + assert!(ids.len() > 50); + } + + #[test] + fn visible_surface_option_only_affects_expansion_and_can_keep_all_faces() { + let mut n = profile_node(); + n.appearance.effects.push(fx()); + let source = n.path_data().unwrap().clone(); + let live = revolve_art(&n).unwrap(); + let expanded = expand_revolve_art(&n).unwrap(); + let count = |n: &Node| n.children().unwrap()[0].children().unwrap().len(); + assert!(count(&expanded) < count(&live) * 3 / 4, "hidden back surfaces are removed"); + assert_eq!(crate::bake_appearance(&n), Some(live.clone()), "other baking retains the full surface"); + n.appearance.effects[0].params["expandVisibleOnly"] = json!(false); + assert_eq!(revolve_art(&n), Some(live.clone()), "the live view ignores the expansion setting"); + assert_eq!(expand_revolve_art(&n), Some(live)); + assert_eq!(n.path_data(), Some(&source)); + n.appearance.effects[0].params["expandVisibleOnly"] = json!(true); + assert_eq!(expand_revolve_art(&n), Some(expanded.clone())); + // Per-item Revolve uses the same option, without trimming another item's artwork. + let effect = n.appearance.effects.remove(0); + n.appearance.items[0].effects_mut().push(effect); + assert_eq!(expand_revolve_art(&n).unwrap().children().unwrap()[0], expanded.children().unwrap()[0]); + } + + #[test] + fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() { + let mut n = profile_node(); + n.appearance.effects.push(fx()); + for change in 0..4 { + let mut sample = n.clone(); + match change { + 0 => sample.opacity = 0.5, + 1 => { + if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] { + f.opacity = 0.4; + } + } + 2 => sample.blend = vectorcraft_doc::color::BlendMode::Multiply, + _ => sample.appearance.effects.push(crate::new_effect("distort.twist", &json!({"angle":45})).unwrap()), + } + assert_eq!(expand_revolve_art(&sample), revolve_art(&sample), "preserve surface for case {change}"); + } + let mut sample = n; + if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] { + f.paint = Paint::Pattern { pattern: "unknown transparency".into(), xf: Affine::IDENTITY }; + } + assert_eq!(expand_revolve_art(&sample), revolve_art(&sample)); + } +} diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index c8a817d56..7962b2db8 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -80,12 +80,13 @@ fn clean_name(name: &str) -> Option { fn add(s: &mut Session, p: &Value) -> Result { const C: &str = "assets.add"; - let ids = match ids_param(p, "ids") { - Some(ids) => { - let d = &s.doc()?.doc; - roots_of(d, d.paint_order(ids)) - } - None => selected_roots(s)?, + let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { + // An explicit list is authoritative: never silently drop malformed or stale ids. + let ids = checked_ids_param(s, p, "ids", C)?; + let d = &s.doc()?.doc; + roots_of(d, d.paint_order(ids)) + } else { + selected_roots(s)? }; if ids.is_empty() { return Err(bad(C, "select the art to collect (or give its ids)")); diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 429dd3146..59dfef535 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -603,7 +603,7 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result { let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); let c = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y)); let size = natural_size(d, &name); - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let id = s.edit("Place Symbol Instance", |d, sel| { let id = d.alloc_id(); d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(c, size) }))?; @@ -843,7 +843,8 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result { let alt = bool_or(p, "alt", false); let size = natural_size(&st.doc, &name); let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set)); - let parent = st.insertion_parent(); + // Only a new Symbol Set needs a layer that takes new art. + let parent = if alt || set.is_some() { st.insertion_parent() } else { st.target_parent()? }; if alt { // Remove instances of this symbol under the brush (within the set, or anywhere). let d = &st.doc; diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index dae94db38..5b4dd8c14 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -79,7 +79,7 @@ pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance: /// Insert a new object looking like `look` (drawing modes apply); select it. pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option) -> Result { - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let mode = s.draw_mode; let inside = s.draw_inside; let behind_of = s.doc()?.selection.in_paint_order(&s.doc()?.doc).first().copied(); @@ -298,7 +298,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { }; parts.push(("Ring", circle(at, rr), radial(at, rr, stops), 1.0)); } - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let id = s.edit("Flare", |d, sel| { let children = parts .into_iter() @@ -352,7 +352,7 @@ fn arc(s: &mut Session, p: &Value) -> Result { } fn grid_group(s: &mut Session, label: &str, paths: Vec) -> Result { - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width); let id = s.edit(label, |d, sel| { let children = paths @@ -385,15 +385,24 @@ fn polar_grid(s: &mut Session, p: &Value) -> Result { grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad)) } -pub(crate) fn anchor_from_json(v: &Value) -> Option { - let p = Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?); - let h_in = point_param(v, "in").unwrap_or(p); - let h_out = point_param(v, "out").unwrap_or(p); - let mut a = Anchor::with_handles(p, h_in, h_out); - if v.get("smooth").and_then(Value::as_bool) == Some(true) { - a.kind = AnchorKind::Smooth; +/// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when +/// absent or null): anything malformed fails rather than being left out or replaced by a default, +/// which would change the path's shape. +pub(crate) fn anchor_from_json(v: &Value, cmd: &str) -> Result { + let coord = + |key: &str| v.get(key).and_then(Value::as_f64).filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("anchor `{key}` must be a number"))); + let p = Point::new(coord("x")?, coord("y")?); + let handle = |key: &str| match v.get(key).filter(|h| !h.is_null()) { + None => Ok(p), + Some(h) => finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(cmd, format!("anchor `{key}` must be [x, y]"))), + }; + let mut anchor = Anchor::with_handles(p, handle("in")?, handle("out")?); + match v.get("smooth").filter(|b| !b.is_null()) { + None | Some(Value::Bool(false)) => {} + Some(Value::Bool(true)) => anchor.kind = AnchorKind::Smooth, + Some(_) => return Err(bad(cmd, "anchor `smooth` must be true or false")), } - Some(a) + Ok(anchor) } fn path_create(s: &mut Session, p: &Value) -> Result { @@ -406,8 +415,8 @@ fn path_create(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_array) .ok_or_else(|| bad("path.create", "missing anchors"))? .iter() - .filter_map(anchor_from_json) - .collect(); + .map(|a| anchor_from_json(a, "path.create")) + .collect::>()?; if anchors.is_empty() { return Err(bad("path.create", "need at least one anchor")); } diff --git a/crates/engine/src/cmd/cut.rs b/crates/engine/src/cmd/cut.rs index a0dfe6d2e..a7a2e47f1 100644 --- a/crates/engine/src/cmd/cut.rs +++ b/crates/engine/src/cmd/cut.rs @@ -221,8 +221,8 @@ fn rect_cut(s: &mut Session, p: &Value) -> Result { const C: &str = "path.rectCut"; let rect = match p.get("rect") { Some(v) => { - let a: Vec = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - let [x, y, w, h] = a[..] else { return Err(bad(C, "rect must be [x, y, width, height]")) }; + // All four or nothing: a malformed entry dropped would cut a different region. + let [x, y, w, h] = finite_numbers(v).ok_or_else(|| bad(C, "rect must be [x, y, width, height]"))?; Rect::new(x, y, x + w, y + h).abs() } None => Rect::from_points(point_req(p, "from", C)?, point_req(p, "to", C)?), diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index bf38e4455..90251db4e 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -160,7 +160,7 @@ pub fn specs() -> Vec { "Add Guide", [], None, - "{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas)} → {index}", + "{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas), layer?: id (the layer it is on, shown, hidden, locked and deleted with it; default the current layer)} → {index}", has_doc, guide_add ), @@ -169,7 +169,7 @@ pub fn specs() -> Vec { "Guides", [], None, - "{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's)}…] the ruler guides", + "{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), shown, editable (its layer shown and unlocked)}…] the ruler guides", has_doc, guide_list ), @@ -178,7 +178,7 @@ pub fn specs() -> Vec { "Select Guides", [], None, - "{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead → {selected: [index…]}", + "{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}", guides_unlocked, guide_select ), @@ -200,6 +200,15 @@ pub fn specs() -> Vec { guides_unlocked, guide_move ), + cmd!( + "guide.setLayer", + "Move Guide to Layer", + [], + None, + "{index?, indexes?: [index…], layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does → {count}", + guides_unlocked, + guide_set_layer + ), cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all), cmd!( "file.documentColorMode", @@ -507,8 +516,15 @@ fn guide_add(s: &mut Session, p: &Value) -> Result { } None => None, }; + let st = s.doc()?; + let layer = match id_param(p, "layer") { + Some(l) if st.doc.node(l).is_some_and(Node::is_layer) => Some(l), + Some(_) => return Err(bad("guide.add", "layer must name a layer or sublayer")), + // Where new art goes (refused when no layer is shown and unlocked), or that group's layer. + None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }), + }; let i = s.edit("New Guide", |d, _| { - d.guides.push(Guide { artboard, ..Guide::new(vertical, pos) }); + d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) }); Ok(d.guides.len() - 1) })?; Ok(json!({ "index": i })) @@ -521,6 +537,11 @@ fn guide_list(s: &mut Session, _: &Value) -> Result { if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) { r["artboard"] = json!(ab); } + if let Some(l) = g.layer { + r["layer"] = json!(l.0); + } + r["shown"] = json!(st.doc.guide_shown(g)); + r["editable"] = json!(st.doc.guide_editable(g)); r }; Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect())) @@ -535,19 +556,26 @@ fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { Ok(i) } +/// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer. +fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + let i = guide_index(s, v, cmd)?; + let d = &s.doc()?.doc; + if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) { + return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); + } + Ok(i) +} + fn guide_select(s: &mut Session, p: &Value) -> Result { let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?; let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::>>()?; if bool_or(p, "toggle", false) { - let mut now = s.doc()?.selection.guides.clone(); - for i in picked { - if let Some(k) = now.iter().position(|g| *g == i) { - now.remove(k); - } else { - now.push(i); + // Added to (or taken from) what is selected, the art included. + s.select(|_, sel| { + for i in picked { + sel.toggle_guide(i); } - } - s.select(|_, sel| sel.set_guides(now))?; + })?; } else { s.select(|_, sel| sel.set_guides(picked))?; } @@ -557,7 +585,7 @@ fn guide_select(s: &mut Session, p: &Value) -> Result { /// Delete ruler guide `index`, else the selected guides, in one undo step → {count}. pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { let gone: Vec = match p.get("index") { - Some(v) => vec![guide_index(s, Some(v), "guide.remove")?], + Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?], None => s.doc()?.selection.guides.clone(), }; if gone.is_empty() { @@ -571,10 +599,39 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { Ok(json!({ "count": gone.len() })) } +/// Put ruler guide `index`, else the selected guides, on layer `layer`, in one undo step. +fn guide_set_layer(s: &mut Session, p: &Value) -> Result { + const C: &str = "guide.setLayer"; + let d = &s.doc()?.doc; + let layer = + id_param(p, "layer").filter(|l| d.node(*l).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "layer must name a layer or sublayer"))?; + // As objects dragged in the Layers panel: never onto a locked layer. + if super::layer::locked_within(d, layer) { + return Err(EngineError::Other("the layer or group is locked".into())); + } + let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { + (Some(v), _) => vec![editable_guide_index(s, Some(v), C)?], + (None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::>>()?, + (None, None) => s.doc()?.selection.guides.clone(), + }; + if moving.is_empty() { + return Err(bad(C, "no guide selected (or give an index)")); + } + s.edit("Move Guide to Layer", |d, _| { + for i in &moving { + if let Some(g) = d.guides.get_mut(*i) { + g.layer = Some(layer); + } + } + Ok(()) + })?; + Ok(json!({ "count": moving.len() })) +} + /// Put ruler guide `index` at `pos`, else move (or copy) the selected guides, in one undo step. pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("index") { - let i = guide_index(s, Some(v), "guide.move")?; + let i = editable_guide_index(s, Some(v), "guide.move")?; let pos = f64_req(p, "pos", "guide.move")?; s.edit("Move Guide", |d, _| { if let Some(g) = d.guides.get_mut(i) { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fb87ce303..7c22774b6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -268,6 +268,8 @@ fn clear(s: &mut Session, p: &Value) -> Result { return super::docmenu::guide_remove(s, &json!({})); } let st = s.doc()?; + // Guides selected with the art go with it (not when ids are given). + let with_guides = explicit.is_none(); let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc)); // Keep the selected ids themselves, without promoting compound members. Delete a selected // ancestor just once; its selected descendants leave with it. Layers themselves are kept. @@ -284,6 +286,10 @@ fn clear(s: &mut Session, p: &Value) -> Result { for id in &ids { d.remove(*id)?; } + if with_guides { + let gone = sel.guides.clone(); + d.retain_guides(sel, |i, _| !gone.contains(&i)); + } sel.clear(); Ok(()) })?; @@ -346,7 +352,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho let off = s.prefs.paste_offset; let st = s.doc()?; let same_doc = clip.source_doc == Some(st.uid); - let parent = st.insertion_parent(); + let parent = st.target_parent()?; // A copied artboard comes back with its art: right of the last artboard, or where it was // (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art. let board = clip diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 1d3c66a7c..328ffa05c 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -107,6 +107,7 @@ fn edit_effects( let Some(n) = d.node_mut(*id) else { continue }; let index = item.effects_item(&n.appearance, cmd)?; if n.appearance.effects_mut(index).is_some_and(&mut f) { + check_revolve_count(n, cmd)?; done.push(*id); } } @@ -117,6 +118,14 @@ fn edit_effects( }) } +fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> { + let count = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects())).filter(|e| e.id == effects::REVOLVE).count(); + if count > 1 { + return Err(bad(cmd, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); + } + Ok(()) +} + pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.apply"; let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?; @@ -140,6 +149,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true })); } let item = item_target(s, p, C)?; + if id == effects::REVOLVE { + for id in appearance_targets(s, p)? { + let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?; + if effects::has_revolve(n) { + return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); + } + } + } let mut index = 0; let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| { fx.push(effect.clone()); @@ -237,6 +255,7 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { let Some(fx) = n.appearance.effects_mut(di) else { continue }; let at = to.min(fx.len()); fx.insert(at, e); + check_revolve_count(n, C)?; landed.get_or_insert((at, di)); done.push(*id); } @@ -258,6 +277,20 @@ fn set_params(s: &mut Session, p: &Value) -> Result { } let visible = p.get("visible").and_then(Value::as_bool); let item = item_target(s, p, C)?; + for id in appearance_targets(s, p)? { + let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + let target = item.effects_item(&n.appearance, C)?; + if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index)) + && e.id == effects::REVOLVE + && visible != Some(false) + { + let mut prospective = effects::merged_params(&e.id, &e.params); + if let (Some(cur), Some(new)) = (prospective.as_object_mut(), params.as_object()) { + cur.extend(new.clone()); + } + effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?; + } + } let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| { let Some(e) = fx.get_mut(index) else { return false }; if let (Value::Object(new), cur) = (¶ms, &mut e.params) { @@ -454,6 +487,7 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { mod tests { use serde_json::json; use vectorcraft_doc::NodeKind; + use vectorcraft_render::effects; use crate::{NodeId, Session}; @@ -468,6 +502,71 @@ mod tests { s.doc().unwrap().doc.node(id).cloned().unwrap() } + #[test] + fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let source = node(&s, id).path_data().unwrap().clone(); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); + let before = effects_for_test(&s, id); + assert_eq!(node(&s, id).path_data(), Some(&source)); + let doc = &s.doc().unwrap().doc; + let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().path_data(), Some(&source)); + assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), before); + let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + assert!(s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).is_err()); + assert!(s.execute("effect.duplicate", &json!({"index":0,"ids":[id.0],"item":null})).is_err()); + s.execute("effect.setParams", &json!({"index":0,"params":{"angle":180,"offset":10},"ids":[id.0],"item":null})).unwrap(); + assert_ne!(effects_for_test(&s, id), before); + assert!(s.execute("effect.setParams", &json!({"index":0,"params":{"edge":"invalid"},"ids":[id.0],"item":null})).is_err()); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(matches!(node(&s, id).kind, NodeKind::Group { .. })); + assert!(!effects::has_revolve(&node(&s, id))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).path_data(), Some(&source)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(effects_for_test(&s, id), before); + } + + fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + effects::revolve_art(&node(s, id)).unwrap() + } + + #[test] + fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":300,"height":300})).unwrap(); + let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap(); + let live = node(&s, id); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true)); + let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + let visible = count(&node(&s, id)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), live); + s.execute("effect.setParams", &json!({"index":0,"item":null,"ids":[id.0],"params":{"expandVisibleOnly":false}})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + assert!(count(&node(&s, id)) > visible); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).appearance.effects[0].params["expandVisibleOnly"], json!(false)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), live); + // The shared command still expands ordinary multiple-fill artwork as before. + let r = s.execute("shape.rectangle", &json!({"x":10,"y":10,"width":20,"height":20})).unwrap(); + let ordinary = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect":"distort.twist","ids":[ordinary.0],"params":{"angle":15}})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[ordinary.0]})).unwrap(); + assert!(node(&s, ordinary).appearance.effects.is_empty()); + } + #[test] fn apply_uses_catalog_defaults_and_undoes() { let (mut s, id) = session_with_rect(); diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs index 0e23dfed2..1f7488cf8 100644 --- a/crates/engine/src/cmd/fileio/tests_epsimport.rs +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -108,6 +108,15 @@ showpage let inner = outer.children().map(|c| (c.len(), c[1].children().map(Vec::len))).unwrap(); assert!(matches!(outer.kind, vectorcraft_doc::NodeKind::Group { clip: false, .. }), "{:?}", outer.kind); assert_eq!(inner, (2, Some(2))); + // One that names the prolog defining its operators without including it (as other apps write + // the format) opens with the layers its program has (#1027). + let named = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%DocumentNeededResources: procset Adobe_IllustratorA_AI3 1.0 0\n%AI3_TemplateBox: 50 50 50 50\n%%EndComments\n\ + %%BeginProlog\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 2 0 0 0 Lb\n(PLAN::Walls) Ln\n0 0 0 1 K\n0 i 1 w\n10 10 m\n90 90 L\nS\nLB\n%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n"; + let r = open(&mut s, "cad.ai", named).unwrap(); + assert_eq!(r["warnings"].as_array().map_or(0, Vec::len), 0, "{r}"); + let d = s.doc().unwrap().doc.clone(); + assert_eq!((d.layers.len(), d.layers[0].name.as_deref(), objects(&s)), (1, Some("PLAN::Walls"), 1)); // A .ait one opens as a new untitled document. let r = open(&mut s, "legacy.ait", ps).unwrap(); assert!(r["title"].as_str().unwrap().starts_with("Untitled"), "{r}"); diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 21fd5c92d..531e9bf99 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -148,9 +148,12 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result point_fields(pv, &mut pt, Affine::IDENTITY).map_err(|e| format!("freeform point {i}: {e}"))?; f.points.push(pt); } - for l in v.get("lines").and_then(Value::as_array).into_iter().flatten() { - let ix = l.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default(); - f.add_line(ix)?; + if let Some(lines) = v.get("lines") { + let lines = lines.as_array().ok_or("`freeform.lines` must be an array")?; + for (i, l) in lines.iter().enumerate() { + let ix = point_indices(l).ok_or_else(|| format!("freeform line {i} must be an array of point indices"))?; + f.add_line(ix).map_err(|e| format!("freeform line {i}: {e}"))?; + } } if let Some(m) = str_param(v, "mode") { f.mode = parse_mode(m)?; @@ -158,6 +161,12 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result Ok(f) } +/// `v` as a list of point indices: `None` unless it is an array of non-negative integers, so no +/// malformed index is dropped or wrapped round. +fn point_indices(v: &Value) -> Option> { + v.as_array()?.iter().map(|i| usize::try_from(i.as_u64()?).ok()).collect() +} + pub(crate) fn parse_mode(m: &str) -> std::result::Result { FreeformMode::parse(m).ok_or_else(|| format!("unknown freeform mode `{m}` (points, lines)")) } @@ -279,11 +288,7 @@ fn delete_point(s: &mut Session, p: &Value) -> Result { fn add_line(s: &mut Session, p: &Value) -> Result { const C: &str = "paint.freeform.addLine"; - let points: Vec = p - .get("points") - .and_then(Value::as_array) - .and_then(|a| a.iter().map(|v| v.as_u64().map(|i| i as usize)).collect()) - .ok_or_else(|| bad(C, "`points` must be an array of point indices"))?; + let points = p.get("points").and_then(point_indices).ok_or_else(|| bad(C, "`points` must be an array of point indices"))?; let line = edit(s, p, C, |f, _| f.add_line(points.clone()))?; Ok(json!({ "line": line })) } @@ -323,3 +328,24 @@ fn get(s: &mut Session, p: &Value) -> Result { v["selected"] = json!(selected); Ok(v) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A line with an index that isn't a point's is refused, not shortened to the indices that are. + #[test] + fn malformed_freeform_lines_are_not_silently_shortened() { + let points = json!([ + {"at": [0, 0], "color": "#ff0000"}, + {"at": [50, 0], "color": "#00ff00"}, + {"at": [100, 0], "color": "#0000ff"} + ]); + for lines in [json!([[0, "broken", 2]]), json!([[0, -1, 2]]), json!([[0, 1.5, 2]]), json!([{"points": [0, 1]}]), json!("not an array")] { + let value = json!({"points": points, "lines": lines, "mode": "lines"}); + assert!(parse_freeform(&value).is_err(), "invalid lines accepted: {value}"); + } + let good = parse_freeform(&json!({"points": points, "lines": [[0, 1, 2]], "mode": "lines"})).unwrap(); + assert_eq!(good.lines, vec![vec![0, 1, 2]]); + } +} diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 6942edff1..aa0a85364 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -270,13 +270,8 @@ pub(crate) fn apply_gradient_edit_in( gp.swatch = None; } if bool_or(p, "reverse", false) { + // Midpoints mirrored with their segments. gp.gradient.reverse(); - // Midpoints belong to the segment to the right; mirror them. - let n = gp.gradient.stops.len(); - let mids: Vec = gp.gradient.stops.iter().map(|s| s.midpoint).collect(); - for i in 0..n { - gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 }; - } } // Angle and aspect edits keep the focal point in its place in the extent ellipse. let before = gp.geom; diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 2077c8e50..fd2153fde 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -831,7 +831,7 @@ fn create(s: &mut Session, p: &Value) -> Result { spec.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()]; spec.rows = vec![vec![3.0, 2.0], vec![5.0, 4.0], vec![4.0, 6.0], vec![7.0, 5.0]]; } - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let id = s.edit("Graph", |d, sel| { let gid = d.alloc_id(); let mut g = Node::group(gid, vec![]); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index a80d3e9d2..be88f9056 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -221,7 +221,7 @@ pub(crate) fn accepts(d: &Document, parent: Option, n: &Node) -> bool { } /// Whether `id` or a container around it is locked. -fn locked_within(d: &Document, id: NodeId) -> bool { +pub(crate) fn locked_within(d: &Document, id: NodeId) -> bool { d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked)) } @@ -462,6 +462,8 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result { for id in &ids { d.remove(*id)?; } + // The guides on the deleted layers go with them. + d.drop_guides_of_deleted_layers(sel); sel.prune(d); Ok(()) })?; @@ -487,6 +489,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result { let mut c = d.reid(&n); if n.is_layer() { c.name = Some(format!("{} copy", n.display_name())); + d.copy_layer_guides(&n, &c); } out.push(d.insert(par, idx + 1, c)?); } @@ -679,7 +682,11 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { for id in &ids { nodes.push(if copy { let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; - d.reid(&n) + let c = d.reid(&n); + if n.is_layer() { + d.copy_layer_guides(&n, &c); + } + c } else { Arc::unwrap_or_clone(d.remove(*id)?) }); diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index a655da4b0..f84cf3f48 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -263,6 +263,8 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { all.extend(taken); all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id))); *ch = all; + // The merged layers' guides go to it too. + d.rehome_guides(into); Ok(()) } @@ -311,6 +313,8 @@ fn flatten(s: &mut Session, p: &Value) -> Result { for h in &hidden { d.remove(*h)?; } + // Hidden layers are discarded with their guides. + d.drop_guides_of_deleted_layers(sel); merge_into(d, into, &others)?; sel.prune(d); Ok(()) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 3d84bd58b..b6385bed0 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -702,7 +702,7 @@ fn trim_marks(s: &mut Session, p: &Value) -> Result { if rects.is_empty() { return Err(bad(C, "nothing to mark")); } - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let ids = s.edit("Create Trim Marks", |d, sel| { let mut ids = vec![]; for r in &rects { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 228410de9..49be3c08a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -316,6 +316,11 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str values.iter().map(|value| checked_id(s, value, command)).collect() } +/// The objects a command acts on: `ids` when given (every one checked), else the selected objects. +pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result> { + if p.get("ids").is_some() { checked_ids_param(s, p, "ids", command) } else { edit::selected_roots(s) } +} + pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result { let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?; if s.doc()?.doc.node(id).is_none() { @@ -324,6 +329,17 @@ pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result(v: &Value) -> Option<[f64; N]> { + let a = v.as_array().filter(|a| a.len() == N)?; + let mut out = [0.0; N]; + for (o, n) in out.iter_mut().zip(a) { + *o = n.as_f64().filter(|n| n.is_finite())?; + } + Some(out) +} + pub(crate) fn point_param(p: &Value, key: &str) -> Option { let a = p.get(key)?.as_array()?; Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 2e9127808..d3569b8a1 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -251,17 +251,26 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf let areas = bool_or(p, "typeAreas", false); let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() }; sc.patterns = transform_patterns(s, p, &ids)?; - let ids = s.edit(label, |d, sel| { - let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() }; - for id in &targets { - if let Some(n) = d.node_mut(*id) - && !(areas && resize_type_area(n, xf)) - { - n.transform(xf, sc); + // A transform of ids that name no object, or an identity transform without a copy, moves + // nothing: the document and its undo history stay as they are, and Transform Again repeats it. + // With a copy, the reply lists the copies, and there are none. + let d = &s.doc()?.doc; + let none = ids.iter().all(|id| d.node(*id).is_none()); + let ids = if none || (!copy && xf == Affine::IDENTITY) { + if copy { vec![] } else { ids } + } else { + s.edit(label, |d, sel| { + let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() }; + for id in &targets { + if let Some(n) = d.node_mut(*id) + && !(areas && resize_type_area(n, xf)) + { + n.transform(xf, sc); + } } - } - Ok(targets) - })?; + Ok(targets) + })? + }; let st = s.doc_mut()?; if st.interaction.is_none() { st.last_transform = Some((xf, copy)); @@ -300,10 +309,19 @@ fn nudge(s: &mut Session, p: &Value) -> Result { let dx = f64_or(p, "dx", 0.0) * k; let dy = f64_or(p, "dy", 0.0) * k; if !s.doc()?.selection.anchors.is_empty() { + // When the anchors move by 0, the document and its undo history stay as they are. + if dx == 0.0 && dy == 0.0 { + return ok(); + } return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy })); } if s.doc()?.selection.is_empty() { - return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": bool_or(p, "copy", false) })); + let copy = bool_or(p, "copy", false); + // When guides move by 0 without a copy, the document and its undo history stay as they are. + if dx == 0.0 && dy == 0.0 && !copy { + return ok(); + } + return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy })); } let ids = selected_roots(s)?; apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p) @@ -386,7 +404,9 @@ fn arrange(s: &mut Session, how: Arrange) -> Result { Arrange::Backward => "Send Backward", Arrange::Back => "Send to Back", }; - s.edit(label, |d, _| { + // When nothing changes order, the document and its undo history stay as they are. + super::overprint::edit_counted(s, label, |d| { + let mut moved = 0; // Arrange within each parent independently (selections may span layers/groups). let mut by_parent: Vec<(Option, Vec)> = vec![]; for id in &ids { @@ -411,10 +431,11 @@ fn arrange(s: &mut Session, how: Arrange) -> Result { }; if to != idx { d.move_node(*id, par, to)?; + moved += 1; } } } - Ok(()) + Ok(moved) })?; ok() } @@ -424,6 +445,11 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result { // Ids are reused after undo: the remembered current layer must still be a layer. let st = s.doc()?; let layer = st.current_layer().or_else(|| st.doc.default_layer()).ok_or_else(|| EngineError::Other("no current layer".into()))?; + // When the selected objects are already the topmost in the current layer, in paint order, the + // document and its undo history stay as they are. + if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) { + return ok(); + } s.edit("Send to Current Layer", |d, _| { for id in &ids { d.move_node(*id, Some(layer), usize::MAX)?; @@ -457,6 +483,11 @@ pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result { fn ungroup(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; + // With no group selected, the document, the selection and the undo history stay as they are. + let d = &s.doc()?.doc; + if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))) { + return ok(); + } s.edit("Ungroup", |d, sel| { let mut new_sel = vec![]; for id in &ids { @@ -492,6 +523,13 @@ fn ungroup(s: &mut Session, _: &Value) -> Result { fn lock(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; + // With every selected object already locked, the document and its undo history stay as they + // are, and the objects are deselected. + let d = &s.doc()?.doc; + if ids.iter().all(|id| d.node(*id).is_none_or(|n| n.locked)) { + s.select(|_, sel| sel.clear())?; + return ok(); + } s.edit("Lock", |d, sel| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -516,6 +554,10 @@ fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { fn unlock_all(s: &mut Session, _: &Value) -> Result { let ids = collect_flag(&s.doc()?.doc, |n| n.locked); + // With nothing locked, the document, the selection and the undo history stay as they are. + if ids.is_empty() { + return Ok(json!({ "count": 0 })); + } s.edit("Unlock All", |d, sel| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -530,6 +572,13 @@ fn unlock_all(s: &mut Session, _: &Value) -> Result { fn hide(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; + // With every selected object already hidden, the document and its undo history stay as they + // are, and the objects are deselected. + let d = &s.doc()?.doc; + if ids.iter().all(|id| d.node(*id).is_none_or(|n| !n.visible)) { + s.select(|_, sel| sel.clear())?; + return ok(); + } s.edit("Hide", |d, sel| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -544,6 +593,10 @@ fn hide(s: &mut Session, _: &Value) -> Result { fn show_all(s: &mut Session, _: &Value) -> Result { let ids = collect_flag(&s.doc()?.doc, |n| !n.visible); + // With nothing hidden, the document, the selection and the undo history stay as they are. + if ids.is_empty() { + return Ok(json!({ "count": 0 })); + } s.edit("Show All", |d, sel| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -602,6 +655,12 @@ fn compound_make(s: &mut Session, _: &Value) -> Result { fn compound_release(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; + // With no compound path selected, the document, the selection and the undo history stay as + // they are. + let d = &s.doc()?.doc; + if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))) { + return ok(); + } s.edit("Release Compound Path", |d, sel| { let mut out = vec![]; for id in &ids { @@ -678,6 +737,11 @@ fn clip_make(s: &mut Session, _: &Value) -> Result { fn clip_release(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; + // With no clip group selected, the document and its undo history stay as they are. + let d = &s.doc()?.doc; + if !ids.iter().any(|id| d.node(*id).is_some_and(Node::clips)) { + return ok(); + } s.edit("Release Clipping Mask", |d, _| { for id in &ids { release_clip(d, *id); @@ -785,6 +849,27 @@ fn set_props(s: &mut Session, p: &Value) -> Result { ), None => None, }; + // When every object already has every value given, the document and its undo history stay as + // they are. + let flag = |k: &str| p.get(k).and_then(Value::as_bool); + let has_values = |n: &Node| { + str_param(p, "name").is_none_or(|v| n.name.as_deref() == Some(v).filter(|v| !v.is_empty())) + && flag("visible").is_none_or(|v| n.visible == v) + && flag("locked").is_none_or(|v| n.locked == v) + && opacity.is_none_or(|v| n.opacity == v) + && blend.is_none_or(|b| n.blend == b) + && flag("isolate").is_none_or(|v| n.isolate == v) + && knockout.is_none_or(|k| n.knockout == k) + && flag("knockoutShape").is_none_or(|v| n.knockout_shape == v) + && data + .iter() + .flatten() + .all(|(k, v)| n.attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref()) + }; + let d = &s.doc()?.doc; + if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) { + return ok(); + } s.edit("Object Properties", |d, _| { for id in &ids { let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; @@ -864,6 +949,11 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect() } +/// Is every move in `moves` a rounding error (1e-9 pt or less on both axes)? +fn moves_nothing(moves: &[(NodeId, Vec2)]) -> bool { + moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) +} + /// What `object.align` aligns to: `to`, else the key object when there is one, else the /// selection. fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> { @@ -886,13 +976,22 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu } } +/// Returns the selected root (one of `ids`) that is the key object or contains it, such as the +/// compound path or selected group the key is in. Align to the key object and Distribute Spacing +/// with a spacing keep it in place. +fn key_root(s: &Session, ids: &[NodeId]) -> Option { + let st = s.doc().ok()?; + let ancestry = st.doc.ancestry(st.selection.key?)?; + ids.iter().copied().find(|id| ancestry.contains(id)) +} + fn align(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let preview = preview_bounds(s, p, "object.align")?; let r = reference_rect(s, p, &ids, preview)?; let h = str_param(p, "horizontal"); let v = str_param(p, "vertical"); - let key = s.doc()?.selection.key; + let key = key_root(s, &ids); let moves: Vec<(NodeId, Vec2)> = { let d = &s.doc()?.doc; items_bounds(d, &ids, preview) @@ -915,6 +1014,10 @@ fn align(s: &mut Session, p: &Value) -> Result { }) .collect() }; + // When nothing moves, the document and its undo history stay as they are. + if moves_nothing(&moves) { + return ok(); + } s.edit("Align", |d, _| { for (id, dv) in &moves { if let Some(n) = d.node_mut(*id) { @@ -965,6 +1068,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result { (*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }) }) .collect(); + // When nothing moves, the document and its undo history stay as they are. + if moves_nothing(&moves) { + return ok(); + } s.edit("Distribute", |d, _| { for (id, dv) in &moves { if let Some(n) = d.node_mut(*id) { @@ -1002,12 +1109,16 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { deltas.push((*id, pos - start(r))); pos += size(r) + gap; } - // With a spacing (aligning to a key object) the key object stays where it is: the others - // are spaced from it. - let key = s.doc()?.selection.key; + // With a spacing (aligning to a key object) the key object stays where it is, with the + // compound path or selected group it is in: the others are spaced from it. + let key = key_root(s, &ids); let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1); let moves: Vec<(NodeId, Vec2)> = deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect(); + // When nothing moves, the document and its undo history stay as they are. + if moves_nothing(&moves) { + return ok(); + } s.edit("Distribute Spacing", |d, _| { for (id, dv) in &moves { if let Some(n) = d.node_mut(*id) { @@ -1057,12 +1168,23 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { let new_rp = scale * gp + (rp - gp) * f(sx, sy); // x, y place the reference point on the page. let page = frame.to_doc() * rp; + // When every value given is the current one, the transform is the identity. Computing it can + // leave a rounding error in it on a rotated object or with Use Preview Bounds. + let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now); + if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) { + return apply_transform(s, "Transform", ids, Affine::IDENTITY, p); + } let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y))); apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p) } fn expand_shape(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; + // With no live shape selected, the document and its undo history stay as they are. + let d = &s.doc()?.doc; + if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { live: Some(_), .. }))) { + return ok(); + } s.edit("Expand Shape", |d, _| { for id in &ids { if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) { @@ -1138,6 +1260,45 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { // A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal. let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?); let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true); + // An ellipse's pie becomes `pie_of(pie)`. An end of 0 is a full turn (Illustrator shows a whole + // ellipse as 0° to 360°). + let end_of = |a: f64| if a == 0.0 { 360.0 } else { a }; + let pie_of = |pie: (f64, f64)| { + let pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of)); + if invert { (pie.1.rem_euclid(360.0), end_of(pie.0)) } else { pie } + }; + // When every value given is the current one, the document and its undo history stay as they + // are. + let st = s.doc()?; + let has_values = |id: &NodeId| { + let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true }; + match live { + Some(l @ LiveShape::Polygon { sides: now, .. }) + if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now)) + && (!equal || l.polygon_sides_equal()) + && polygon_angle.is_none_or(|a| l.polygon_angle() == Some(a)) + && side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius).is_none_or(|r| l.polygon_radius() == Some(r))) => + { + return false; + } + Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false, + _ => {} + } + if radius.is_none() && kind.is_none() { + return true; + } + let Some(c) = LiveCorners::new(path, live.as_ref()) else { return true }; + let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id))); + // Setting corners folds a scale left in a rectangle's transform into its size, which + // changes it. + live.as_ref().is_none_or(|l| l.folded() == *l) + && picked + .iter() + .all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd)) + }; + if ids.iter().all(has_values) { + return ok(); + } s.edit("Live Shape", |d, sel| { for id in &ids { let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; @@ -1163,12 +1324,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { && (pie_start.is_some() || pie_end.is_some() || invert) { if let LiveShape::Ellipse { pie, .. } = l { - // An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°). - let end_of = |a: f64| if a == 0.0 { 360.0 } else { a }; - *pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of)); - if invert { - *pie = (pie.1.rem_euclid(360.0), end_of(pie.0)); - } + *pie = pie_of(*pie); } *path = l.to_path(); } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b98c6c828..2bc62004b 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only", has_doc, |s, p| set_paint(s, p, true) ), @@ -375,18 +375,41 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result { /// remember the paint. fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result { let cmd = if fill { "paint.setFill" } else { "paint.setStroke" }; - // New art gets the paint fitted to itself, not placed where this one is. - if fill { - s.paint.fill = unplaced(&paint); - } else { - s.paint.stroke = unplaced(&paint); - } - if bool_or(p, "focus", true) { - s.fill_active = fill; + // Characters selected with the Type tool take the paint, not the whole type object (#1063). + if p.get("ids").is_none() + && p.get("item").is_none() + && let Some((id, a, b)) = super::textedit::editing_range(s)? + && a != b + { + s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; + let (xf, lb) = (t.xf, t.local_bounds()); + vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| { + if fill { + st.fill = place_run_paint(&paint, p, xf, lb); + } else { + if !paint.is_none() { + run_stroke_weight(st); + } + st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width)); + } + }); + super::typecmd::refresh_bounds(t); + Ok(()) + })?; + if bool_or(p, "focus", true) { + s.fill_active = fill; + } + s.remember_paint(&paint); + return ok(); } + // Resolve the requested targets before mutating defaults or the active proxy. let item = item_target(s, p, cmd)?.of_kind(s, fill); let selected = item.targets(s, p)?; let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); + // The gradient mesh point last clicked with the Mesh tool or Direct Selection: a solid fill + // recolours just that point of its mesh. + let mesh_point = if fill && matches!(paint, Paint::Solid { .. }) { focused_mesh_point(s) } else { None }; let label = if fill { "Fill Color" } else { "Stroke Color" }; edit_items_then( s, @@ -396,6 +419,15 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result Result Option<(NodeId, usize)> { + let o = s.tool_options(); + let pt = o.get("meshPoint")?; + Some((NodeId(pt.get("id")?.as_u64()?), usize::try_from(pt.get("index")?.as_u64()?).ok()?)) +} + /// A type run's stroke; a painted stroke on a run without one gets 1 pt. fn set_run_stroke(style: &mut CharStyle, paint: Paint) { if !paint.is_none() { @@ -436,7 +486,6 @@ fn set_run_stroke(style: &mut CharStyle, paint: Paint) { } fn swap(s: &mut Session, p: &Value) -> Result { - std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke); let ids = paint_targets(s, p)?; if !ids.is_empty() { s.edit("Swap Fill and Stroke", |d, _| { @@ -458,11 +507,11 @@ fn swap(s: &mut Session, p: &Value) -> Result { Ok(()) })?; } + std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke); ok() } fn default_paint(s: &mut Session, p: &Value) -> Result { - super::newart::reset(s); let ids = paint_targets(s, p)?; if !ids.is_empty() { s.edit("Default Fill and Stroke", |d, _| { @@ -481,6 +530,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { Ok(()) })?; } + super::newart::reset(s); ok() } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index c8568d9d2..62204f755 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -185,14 +185,7 @@ const MAX_PARA: f64 = 1296.0; fn set_format(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setFormat"; - let ids: Vec = { - let ids = targets(s, p)?; - let d = &s.doc()?.doc; - ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect() - }; - if ids.is_empty() { - return Err(bad(C, "no text objects selected")); - } + let ids = super::typecmd::text_targets(s, p, C)?; let num = |k: &str| p.get(k).and_then(Value::as_f64); let flag = |k: &str| p.get(k).and_then(Value::as_bool); let kerning = match p.get("kerning") { @@ -222,6 +215,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "direction", "leadingModel", "charAlign", + "proportionalMetrics", "burasagari", "composer", ]; @@ -285,15 +279,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result { s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?; + let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?; super::typecmd::style_chars(t, range, |st| { if let Some(k) = kerning { st.kerning = k; } - if let Some(v) = num("baselineShift") { - st.baseline_shift = v.clamp(-1296.0, 1296.0); + if let Some(v) = baseline_shift { + st.baseline_shift = v; } - if let Some(v) = num("hScale") { - st.h_scale = v.clamp(1.0, 10000.0); + if let Some(v) = h_scale { + st.h_scale = v; } if let Some(v) = num("vScale") { st.v_scale = v.clamp(1.0, 10000.0); @@ -319,6 +315,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = char_align { st.char_align = v; } + if let Some(v) = flag("proportionalMetrics") { + st.proportional_metrics = v; + } }); let span = super::typecmd::para_span(range, t); t.edit_paras(span, |para| { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index a4f6feafc..1c2683cfc 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -129,7 +129,7 @@ fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<&mut PathDa fn append_anchor(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; - let a = anchor_from_json(p).ok_or_else(|| bad("path.appendAnchor", "missing x/y"))?; + let a = anchor_from_json(p, "path.appendAnchor")?; s.edit("Pen", |d, sel| { let path = path_mut(d, id)?; match path.subpaths.last_mut() { @@ -217,12 +217,21 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { let data = PathData::new( subs.iter() .map(|sp| { - SubPath::new( - sp.get("anchors").and_then(Value::as_array).map(|a| a.iter().filter_map(anchor_from_json).collect()).unwrap_or_default(), - bool_or(sp, "closed", false), - ) + let anchors = sp + .get("anchors") + .and_then(Value::as_array) + .ok_or_else(|| bad("path.setAnchors", "each subpath needs an anchors array"))? + .iter() + .map(|a| anchor_from_json(a, "path.setAnchors")) + .collect::>>()?; + let closed = match sp.get("closed") { + None | Some(Value::Null) => false, + Some(Value::Bool(v)) => *v, + Some(_) => return Err(bad("path.setAnchors", "closed must be a boolean")), + }; + Ok(SubPath::new(anchors, closed)) }) - .collect(), + .collect::>>()?, ); s.edit("Reshape", |d, _| { *path_mut(d, id)? = data; diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 9fee1ce57..b881d52b8 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -309,13 +309,22 @@ fn transformed(mut node: Node, m: Affine) -> Node { /// `[x, y, width, height]` with a positive size. fn rect_param(p: &Value) -> Result> { let Some(v) = p.get("rect") else { return Ok(None) }; - let n: Vec = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - match n[..] { - [x, y, w, h] if w > 0.0 && h > 0.0 => Ok(Some(Rect::new(x, y, x + w, y + h))), + // All four or nothing: a malformed entry dropped would shift the others ([0, "bad", 10, 40, 50]). + match finite_numbers(v) { + Some([x, y, w, h]) if w > 0.0 && h > 0.0 => Ok(Some(Rect::new(x, y, x + w, y + h))), _ => Err(bad(PLACE, "rect must be [x, y, width, height] with a positive width and height")), } } +/// `at`, the `[x, y]` the art is centred on (absent or null: none); anything else fails rather than +/// placing the art as if no point were given. +fn at_param(p: &Value) -> Result> { + p.get("at") + .filter(|v| !v.is_null()) + .map(|v| finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(PLACE, "at must be [x, y]"))) + .transpose() +} + /// The transform that puts new art with the natural box `natural` where `old` is: an image's scale /// and rotation relative to its 100% size (its resolution's) and its centre are kept (with /// `image_xf`); other objects keep their centre. @@ -342,7 +351,7 @@ fn fit(natural: Rect, r: Rect) -> Affine { fn place(s: &mut Session, p: &Value) -> Result { let replace = bool_or(p, "replace", false); let template = bool_or(p, "template", false); - let at = point_param(p, "at"); + let at = at_param(p)?; let rect = rect_param(p)?; if replace && (template || at.is_some() || rect.is_some()) { return Err(bad(PLACE, "replace keeps the replaced object's place and transform: drop template, at and rect")); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 77b97e31c..f2cc7bccf 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -152,7 +152,8 @@ fn render(doc: &Document, nodes: Vec, region: Rect, scale: f64, anti_alias tmp.layers = vec![Arc::new(layer)]; let mut r = vectorcraft_render::Renderer::new(); r.threads = 0; - r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, ..Default::default() }) + // Art Optimized is supersampled, as in exports: shapes meeting edge to edge leave no seam. + r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, supersample: true, ..Default::default() }) } /// Pixels per point of raster effects rendered as images (the document's raster effects diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 8e018e799..319a7b33b 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -294,17 +294,35 @@ fn key(s: &mut Session, p: &Value) -> Result { ok() } -/// `[[subpath, anchor]…]` anchor references (malformed entries are left out). -pub(crate) fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> { - v.and_then(Value::as_array) - .map(|a| a.iter().filter_map(|x| Some((x.get(0)?.as_u64()? as usize, x.get(1)?.as_u64()? as usize))).collect()) - .unwrap_or_default() +/// `[[subpath, anchor]…]` anchor references of `cmd`: a malformed entry fails rather than being +/// left out, which would act on other anchors than the ones given. +pub(crate) fn checked_refs(v: Option<&Value>, cmd: &str) -> Result> { + let refs = v.and_then(Value::as_array).ok_or_else(|| bad(cmd, "`anchors` must be an array of [subpath, anchor] pairs"))?; + refs.iter() + .map(|v| { + let Some([subpath, anchor]) = v.as_array().map(Vec::as_slice) else { + return Err(bad(cmd, format!("invalid anchor reference {v}: expected [subpath, anchor]"))); + }; + let coordinate = |v: &Value| { + v.as_u64() + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| bad(cmd, format!("invalid anchor index {v}: expected a non-negative integer"))) + }; + Ok((coordinate(subpath)?, coordinate(anchor)?)) + }) + .collect() } fn anchors(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("select.anchors", "missing id"))?; - let refs = parse_refs(p.get("anchors")); - let mode = str_param(p, "mode").unwrap_or("set").to_string(); + const C: &str = "select.anchors"; + let id = checked_id(s, p.get("id").ok_or_else(|| bad(C, "missing id"))?, C)?; + let refs = checked_refs(p.get("anchors"), C)?; + let mode = match str_param(p, "mode") { + None | Some("set") => "set", + Some("add") => "add", + Some("toggle") => "toggle", + Some(m) => return Err(bad(C, format!("unknown mode {m:?}"))), + }; s.select(|_, sel| { if mode == "set" { sel.clear(); @@ -323,11 +341,16 @@ fn anchors(s: &mut Session, p: &Value) -> Result { } fn anchors_many(s: &mut Session, p: &Value) -> Result { - let items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = p - .get("items") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|it| Some((NodeId(it.get("id")?.as_u64()?), parse_refs(it.get("anchors")).into_iter().collect()))).collect()) - .unwrap_or_default(); + const C: &str = "select.anchorsMany"; + let raw = p.get("items").and_then(Value::as_array).ok_or_else(|| bad(C, "`items` must be an array of {id, anchors}"))?; + let items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = raw + .iter() + .map(|it| { + let id = checked_id(s, it.get("id").ok_or_else(|| bad(C, "each item needs an id"))?, C)?; + let refs = checked_refs(it.get("anchors"), C)?; + Ok((id, refs.into_iter().collect())) + }) + .collect::>()?; // `add: true` is the older spelling of mode add. let mode = match str_param(p, "mode") { Some(m @ ("set" | "add" | "toggle" | "subtract")) => m, diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index d397056ad..de4051e9a 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -100,7 +100,12 @@ impl StrokeChange { let dash = match p.get("dash") { None => None, Some(Value::Null) => Some(None), - Some(Value::Array(a)) => Some(Some(a.iter().filter_map(Value::as_f64).collect::>()).filter(|d| !d.is_empty())), + Some(Value::Array(a)) => { + // Do not silently omit malformed segments and change the user's dash pattern. + let values: Vec = + a.iter().map(|v| v.as_f64().ok_or_else(|| bad(cmd, format!("dash lengths must be numbers, got {v}")))).collect::>()?; + Some(Some(values).filter(|d| !d.is_empty())) + } Some(v) => return Err(bad(cmd, format!("dash must be a list of lengths or null, got {v}"))), }; Ok(Self { @@ -167,9 +172,6 @@ fn no_targets(ids: &[vectorcraft_doc::NodeId], p: &Value) -> bool { fn stroke_set(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.set"; let change = StrokeChange::parse(p, C)?; - if let Some(w) = change.weight { - s.paint.stroke_width = w; - } let item = item_target(s, p, C)?.of_kind(s, false); let mut ids = item.targets(s, p)?; let new_art = no_targets(&ids, p); @@ -216,10 +218,6 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { st.profile = pr.clone(); } }; - // With nothing selected the Stroke panel sets up the next object drawn. - if new_art && let Some(st) = s.new_art_stroke_mut() { - set(st); - } edit_items(s, &ids, item, C, "Stroke", false, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind @@ -235,6 +233,14 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; + // Commit the new-art template only after the complete request is validated + // and the selected artwork edit has succeeded. + if let Some(w) = change.weight { + s.paint.stroke_width = w; + } + if new_art && let Some(st) = s.new_art_stroke_mut() { + set(st); + } ok() } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 0df32a979..8d6ec5482 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}", has_doc, set_range_style ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Get Text Range", [], None, - "{id, start?, end?} → {text, runs: [{text, style}], length}", + "{id, start?, end?, effective?: bool = false} → {text, runs: [{text, style}], length}; effective uses document-point size/leading/baseline shift and transformed horizontal scale; default returns stored local styles for rich-text copying", has_doc, get_range ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}", has_doc, create_in_path ), @@ -174,7 +174,12 @@ fn get_range(s: &mut Session, p: &Value) -> Result { let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); let (a, b) = range_of(p, len); - let runs = edit::slice_runs(&t.runs, a, b); + let mut runs = edit::slice_runs(&t.runs, a, b); + if p.get("effective").and_then(Value::as_bool).unwrap_or(false) { + for run in &mut runs { + run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?; + } + } let text: String = runs.iter().map(|r| r.text.as_str()).collect(); Ok(json!({"text": text, "runs": runs, "length": len})) } @@ -203,6 +208,7 @@ pub(crate) struct CharChange { position: Option, small_caps: Option>, char_align: Option, + proportional_metrics: Option, } /// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). @@ -286,6 +292,7 @@ impl CharChange { position, small_caps, char_align: char_align_param(p, cmd)?, + proportional_metrics: flag("proportionalMetrics"), }; if c.size.is_some_and(|v| v <= 0.0) { return Err(bad(cmd, "size must be positive")); @@ -314,6 +321,16 @@ impl CharChange { && self.position.is_none() && self.small_caps.is_none() && self.char_align.is_none() + && self.proportional_metrics.is_none() + } + + fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> { + use super::typecmd::local_type_value; + self.size = self.size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, cmd)).transpose()?; + self.leading = self.leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, cmd)).transpose()).transpose()?; + self.baseline_shift = self.baseline_shift.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?; + self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?; + Ok(()) } pub(crate) fn apply(&self, st: &mut CharStyle) { @@ -338,10 +355,10 @@ impl CharChange { st.font_version = None; } if let Some(v) = self.size { - st.size = v.clamp(0.1, 1296.0); + st.size = v; } if let Some(v) = self.leading { - st.leading = v.map(|l| l.clamp(0.1, 5000.0)); + st.leading = v; } if let Some(v) = self.tracking { st.tracking = v.clamp(-1000.0, 10000.0); @@ -350,10 +367,10 @@ impl CharChange { st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0)); } if let Some(v) = self.baseline_shift { - st.baseline_shift = v.clamp(-1296.0, 1296.0); + st.baseline_shift = v; } if let Some(v) = self.h_scale { - st.h_scale = v.clamp(1.0, 10000.0); + st.h_scale = v; } if let Some(v) = self.v_scale { st.v_scale = v.clamp(1.0, 10000.0); @@ -391,6 +408,9 @@ impl CharChange { if let Some(v) = self.char_align { st.char_align = v; } + if let Some(v) = self.proportional_metrics { + st.proportional_metrics = v; + } } } @@ -445,12 +465,13 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "missing `id`: give one, or select one type object")), }, }; - let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?; + let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?; if change.is_empty() { return Err(bad(C, "nothing to change")); } let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some()); let t = text_ref(s, id)?; + change.localize(&t, C)?; let (a, b) = range_of(p, edit::runs_len(&t.runs)); let n = s.edit("Character", |d, _| { let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; @@ -485,16 +506,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { if !on_path && !path.is_closed() && path.bounds().is_none_or(|b| b.width() < 1.0 || b.height() < 1.0) { return Err(bad(C, "area type needs a path that encloses an area")); } - let mut style = CharStyle::default(); - if let Some(v) = p.get("size").and_then(Value::as_f64) { - style.size = v.clamp(0.1, 1296.0); - } - if let Some(f) = str_param(p, "font") { - style.font_family = f.to_string(); - } - if !s.paint.fill.is_none() && s.paint.fill != Paint::solid(vectorcraft_color::Color::WHITE) { - style.fill = s.paint.fill.clone(); - } + let style = super::create::new_type_style(s, p); let text = str_param(p, "text").unwrap_or("").to_string(); let start = match point_param(p, "at") { Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0), @@ -551,7 +563,7 @@ enum Step { } /// The Type tool's selected text while it edits (its typing session ends first: one undo step). -fn editing_range(s: &mut Session) -> Result> { +pub(crate) fn editing_range(s: &mut Session) -> Result> { let o = s.tool_options(); let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return Ok(None) }; if super::edit::typing_in_progress(s) { @@ -614,13 +626,27 @@ fn type_step(s: &mut Session, p: &Value) -> Result { s.edit("Character", |d, _| { for &(id, a, b) in &ranges { let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; - edit::style_range(&mut t.runs, a, b, |st| match step { - Step::Size => st.size = (st.size + inc).clamp(0.1, 1296.0), - Step::Leading => st.leading = Some((st.effective_leading() + inc).clamp(0.1, 5000.0)), - Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0), - Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)), - Step::BaselineShift => st.baseline_shift = (st.baseline_shift + inc).clamp(-1296.0, 1296.0), - }); + let points = if matches!(step, Step::Size | Step::Leading | Step::BaselineShift) { + t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points + } else { + 1.0 + }; + let mut runs = edit::slice_runs(&t.runs, a, b); + for run in &mut runs { + let st = &mut run.style; + match step { + Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?, + Step::Leading => { + st.leading = Some(super::typecmd::local_type_value(t, st.effective_leading() * points + inc, (0.1, 5000.0), false, C)?); + } + Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0), + Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)), + Step::BaselineShift => { + st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?; + } + } + } + edit::replace_range_styled(&mut t.runs, a, b, &runs); refresh_bounds(t); } Ok(()) diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 549e95fdf..a46e98591 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -133,14 +133,44 @@ fn to_attrs(v: &T) -> Map { m } +/// Every attribute name `T` reads, including those its JSON leaves out at their defaults (Auto +/// leading and kerning, no mojikumi…): the field names serde's derive hands `deserialize_struct`. +/// Not `justify_auto`, which [`to_map`] turns into `"justify": "Auto"`. +fn attr_names() -> Vec<&'static str> { + use serde::de::{Error as _, Visitor, value::Error}; + + struct Names<'a>(&'a mut &'static [&'static str]); + impl<'de> serde::Deserializer<'de> for Names<'_> { + type Error = Error; + fn deserialize_any>(self, _: V) -> std::result::Result { + Err(Error::custom("not a struct")) + } + fn deserialize_struct>(self, _: &'static str, fields: &'static [&'static str], _: V) -> std::result::Result { + *self.0 = fields; + Err(Error::custom("field names only")) + } + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf option unit + unit_struct newtype_struct seq tuple tuple_struct map enum identifier ignored_any + } + } + + let mut fields: &'static [&'static str] = &[]; + // Always an error: the probe only records the names. + let _ = T::deserialize(Names(&mut fields)); + fields.iter().copied().filter(|&f| f != "justify_auto").collect() +} + /// `base` with `attrs` written over it (unknown or ill-typed attributes are an error). fn with_attrs(base: &T, attrs: &Map, cmd: &str) -> Result { let mut m = to_attrs(base); if let Some(full) = to_map(base) { m.insert("style_name".into(), full.get("style_name").cloned().unwrap_or(Value::Null)); } + let names = attr_names::(); for (k, v) in attrs { - if !m.contains_key(k) { + // Not only the keys `base` writes: those at their defaults (Auto leading…) are left out. + if !m.contains_key(k) && !names.contains(&k.as_str()) { return Err(bad(cmd, format!("unknown attribute `{k}`"))); } m.insert(k.clone(), v.clone()); @@ -575,6 +605,44 @@ mod tests { assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill); } + /// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no + /// mojikumi) are still attributes a style can set, and a style captured with one applies to + /// text at the default, is duplicated and comes back out of a library. + #[test] + fn styles_take_attributes_left_out_at_their_defaults() { + let (mut s, id) = session_with_text("A"); + s.execute("charStyle.new", &json!({"name": "Kerned", "attrs": {"kerning": 40, "leading": 30}})).unwrap(); + s.execute("charStyle.setAttrs", &json!({"name": "Kerned", "attrs": {"kerning": 20, "small_caps": 70}})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Punctuation", "attrs": {"mojikumi": "lineEndHalf"}})).unwrap(); + s.execute("paraStyle.setAttrs", &json!({"name": "Punctuation", "attrs": {"direction": "rightToLeft"}})).unwrap(); + for bad in [json!({"nope": 1}), json!({"justify_auto": true})] { + assert!(s.execute("paraStyle.new", &json!({"name": "Bad", "attrs": bad})).is_err(), "{bad}"); + } + // Captured from text with explicit leading, applied to fresh Auto-leading text. + s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Spaced"})).unwrap(); + let fresh = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(runs(&s, fresh)[0].style.leading, None); + for clear in [false, true] { + s.execute("charStyle.apply", &json!({"name": "Spaced", "id": fresh, "clearOverrides": clear})).unwrap(); + assert_eq!(runs(&s, fresh)[0].style.leading, Some(30.0)); + } + let copy = s.execute("charStyle.duplicate", &json!({"name": "Spaced"})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == copy).unwrap().attrs["leading"], 30.0); + s.execute("charStyle.apply", &json!({"name": "Kerned", "id": fresh})).unwrap(); + let st = runs(&s, fresh)[0].style.clone(); + assert_eq!((st.kerning, st.small_caps), (Some(20.0), Some(70.0))); + s.execute("paraStyle.apply", &json!({"name": "Punctuation", "id": id})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() }; + assert_eq!((t.para.mojikumi, t.para.direction), (vectorcraft_doc::Mojikumi::LineEndHalf, Some(vectorcraft_doc::ParaDirection::RightToLeft))); + // A library keeps the captured attributes and makes the style again where it's used. + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let lib = s.execute("library.add", &json!({"kind": "charStyle", "name": "Lib Spaced"})).unwrap(); + let other = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "C"})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.use", &json!({"library": lib["library"], "kind": "charStyle", "item": lib["name"]})).unwrap(); + assert_eq!(runs(&s, other)[0].style.leading, Some(30.0)); + } + #[test] fn paragraph_styles_and_survive_save() { let (mut s, id) = session_with_text("Body"); diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs index 6da8b4539..e916f65ce 100644 --- a/crates/engine/src/cmd/textwrap.rs +++ b/crates/engine/src/cmd/textwrap.rs @@ -8,7 +8,6 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape}; use vectorcraft_geom::{PathData, shapes}; -use super::edit::selected_roots; use super::typecmd::refresh_bounds; use super::*; @@ -36,19 +35,12 @@ pub fn specs() -> Vec { ] } -fn targets(s: &Session, p: &Value) -> Result> { - match p.get("ids").and_then(Value::as_array) { - Some(a) => Ok(a.iter().filter_map(Value::as_u64).map(NodeId).collect()), - None => selected_roots(s), - } -} - fn wrap_param(p: &Value, base: TextWrap) -> TextWrap { TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) } } fn make(s: &mut Session, p: &Value) -> Result { - let ids = targets(s, p)?; + let ids = checked_ids_or_selection(s, p, "object.textWrap.make")?; let w = wrap_param(p, TextWrap::default()); s.edit("Make Text Wrap", |d, _| { for id in &ids { @@ -66,7 +58,7 @@ fn make(s: &mut Session, p: &Value) -> Result { fn release(s: &mut Session, p: &Value) -> Result { let ids: Vec = { let d = &s.doc()?.doc; - targets(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect() + checked_ids_or_selection(s, p, "object.textWrap.release")?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect() }; if ids.is_empty() { return Err(EngineError::Other("Text Wrap: select a wrap object".into())); @@ -83,7 +75,7 @@ fn release(s: &mut Session, p: &Value) -> Result { } fn options(s: &mut Session, p: &Value) -> Result { - let ids = targets(s, p)?; + let ids = checked_ids_or_selection(s, p, "object.textWrap.options")?; let current = { let d = &s.doc()?.doc; ids.iter().find_map(|i| d.node(*i).and_then(|n| n.wrap)).unwrap_or_default() @@ -212,6 +204,35 @@ mod tests { vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).lines.iter().map(|l| l.avail).collect() } + /// Invalid explicit ids must not turn a requested multi-object edit into a partial one. + #[test] + fn text_wrap_commands_reject_malformed_target_lists_without_editing() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap(); + let other = s.execute("shape.rectangle", &json!({"x": 120, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + + let before = s.doc().unwrap().history.undo.len(); + for command in ["object.textWrap.make", "object.textWrap.release", "object.textWrap.options"] { + for ids in [json!([id, "invalid"]), json!([id, 99999]), json!({"not": "an array"})] { + let e = s.execute(command, &json!({"ids": ids, "offset": 12})).unwrap_err().to_string(); + assert!(e.contains("id") || e.contains("array"), "{command}: {e}"); + assert_eq!(s.doc().unwrap().history.undo.len(), before, "{command} added history on error"); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none()); + } + } + + s.execute("object.textWrap.make", &json!({"ids": [id]})).unwrap(); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_some()); + assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none()); + s.execute("object.textWrap.options", &json!({"ids": [id], "offset": 12})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.unwrap().offset, 12.0); + s.execute("object.textWrap.release", &json!({"ids": [id]})).unwrap(); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none()); + } + #[test] fn area_type_wraps_around_objects_above_and_follows_edits() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 2395b3f5a..38f2c3a1c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)", + "{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)", has_doc, set_style ), @@ -289,6 +289,22 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result Result { + if !value.is_finite() { + return Err(bad(cmd, "type dimension must be finite")); + } + let scale = t.style_scale().ok_or_else(|| bad(cmd, "text transform is collapsed or unrepresentable"))?; + let local = value.clamp(limits.0, limits.1) / if horizontal { scale.horizontal } else { scale.points }; + // Bound local dimensions too: an extreme inverse scale can produce a finite size + // whose leading, glyph advances or layout bounds overflow before the edit's geometry check. + if !local.is_finite() || local.abs() > crate::MAX_COORD || (value != 0.0 && local == 0.0) { + return Err(bad(cmd, "type dimension cannot be represented with this text transform")); + } + Ok(local) +} + /// Optional `start`/`end` byte offsets of a text command: the character range it styles and the /// paragraphs it touches. Without either, the command applies to all the text. #[derive(Clone, Copy, Debug, PartialEq)] @@ -415,6 +431,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result { s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?; + let leading = leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, C)).transpose()).transpose()?; let before = protect.then(|| t.runs.clone()); style_chars(t, range, |st| { if let Some(f) = &font { @@ -428,7 +446,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { st.font_version = None; } if let Some(v) = size { - st.size = v.clamp(0.1, 1296.0); + st.size = v; } if let Some(l) = leading { st.leading = l; @@ -628,7 +646,7 @@ fn area_options(s: &mut Session, p: &Value) -> Result { fn reshape_area(s: &mut Session, p: &Value) -> Result { const C: &str = "text.reshapeArea"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let mut refs = super::select::parse_refs(p.get("anchors")); + let mut refs = super::select::checked_refs(p.get("anchors"), C)?; refs.sort_unstable(); refs.dedup(); if refs.is_empty() { @@ -841,6 +859,34 @@ mod area_tests { } } + /// Proportional Metrics (#966): a character attribute, saved only when on. + #[test] + fn proportional_metrics_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.proportional_metrics); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.proportional_metrics)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.proportional_metrics); + // Characters selected with the Type tool: only the range takes it (雅 is 3 bytes). + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect(); + assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]); + // Styles written without it (documents from before it) read as off. + let old = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert!(old.get("proportionalMetrics").is_none()); + assert!(!serde_json::from_value::(old).unwrap().proportional_metrics); + } + #[test] fn new_type_is_composed_with_line_end_half_width_punctuation() { use vectorcraft_doc::Mojikumi; diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index a47551702..79b746670 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -49,11 +49,6 @@ fn names_param(p: &Value, cmd: &str, fallback: Option, nothing: &str) -> } } -/// The objects a bind acts on: `ids` when given (every one checked), else the selection. -fn target_ids(s: &Session, p: &Value, cmd: &str) -> Result> { - if p.get("ids").is_some() { checked_ids_param(s, p, "ids", cmd) } else { selected_roots(s) } -} - /// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked /// layer is not the document's to rewrite, and a text variable only drives type. fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> { @@ -203,7 +198,7 @@ fn list(s: &mut Session, _p: &Value) -> Result { fn bind(s: &mut Session, p: &Value) -> Result { const C: &str = "variable.bind"; let name = named(str_param(p, "variable"), "variable", C)?; - let ids = target_ids(s, p, C)?; + let ids = checked_ids_or_selection(s, p, C)?; if ids.is_empty() { return Err(bad(C, "nothing to bind")); } @@ -218,7 +213,7 @@ fn bind(s: &mut Session, p: &Value) -> Result { fn unbind(s: &mut Session, p: &Value) -> Result { const C: &str = "variable.unbind"; - let ids = target_ids(s, p, C)?; + let ids = checked_ids_or_selection(s, p, C)?; let name = defined_variable(s, p, C)?; let n = s.edit("Unbind Variable", |d, _| { let mut n = 0; diff --git a/crates/engine/src/cmd/views.rs b/crates/engine/src/cmd/views.rs index 65c55f93e..af1693dfd 100644 --- a/crates/engine/src/cmd/views.rs +++ b/crates/engine/src/cmd/views.rs @@ -55,12 +55,16 @@ fn list_views(s: &mut Session, _: &Value) -> Result { fn new_view(s: &mut Session, p: &Value) -> Result { const C: &str = "view.saved.new"; - let center = p.get("center").and_then(Value::as_array).and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); - let center = center.ok_or_else(|| bad(C, "missing center [x, y]"))?; + let [x, y] = p.get("center").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "center must be [x, y]"))?; let zoom = f64_req(p, "zoom", C)?; if !(zoom.is_finite() && zoom > 0.0) { return Err(bad(C, "zoom must be positive")); } + // Given, the rotation is a number: nothing malformed falls back to 0°. + let rotation = match p.get("rotation") { + Some(r) => r.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(C, "rotation must be a number of degrees"))?, + None => 0.0, + }; let st = s.doc()?; if st.doc.views.len() >= MAX_VIEWS { return Err(bad(C, format!("a document keeps at most {MAX_VIEWS} views"))); @@ -72,7 +76,7 @@ fn new_view(s: &mut Session, p: &Value) -> Result { if st.doc.views.iter().any(|v| v.name == name) { return Err(bad(C, format!("a view named `{name}` exists"))); } - let view = SavedView { name: name.clone(), center, zoom, rotation: f64_or(p, "rotation", 0.0) }; + let view = SavedView { name: name.clone(), center: Point::new(x, y), zoom, rotation }; let index = s.edit("New View", |d, _| { d.views.push(view); Ok(d.views.len() - 1) @@ -108,6 +112,29 @@ mod tests { use crate::Session; + /// Malformed dimensions are rejected rather than saving an arbitrary or partial viewport. + #[test] + fn saved_views_require_exact_finite_coordinates_and_rotation() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let before = s.doc().unwrap().history.undo.len(); + for params in [ + json!({"center": [10], "zoom": 2}), + json!({"center": [10, 20, 30], "zoom": 2}), + json!({"center": [10, "bad"], "zoom": 2}), + json!({"center": [10, 20], "zoom": 2, "rotation": "bad"}), + json!({"center": [10, 20], "zoom": 2, "rotation": null}), + ] { + assert!(s.execute("view.saved.new", ¶ms).is_err(), "accepted {params}"); + assert!(s.doc().unwrap().doc.views.is_empty()); + assert_eq!(s.doc().unwrap().history.undo.len(), before); + } + s.execute("view.saved.new", &json!({"center": [10, 20], "zoom": 2, "rotation": -15})).unwrap(); + let views = &s.doc().unwrap().doc.views; + assert_eq!(views.len(), 1); + assert_eq!(views[0].rotation, -15.0); + } + #[test] fn save_rename_delete_views() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs index e809440f2..0ca8294f3 100644 --- a/crates/engine/src/cmd/webexport.rs +++ b/crates/engine/src/cmd/webexport.rs @@ -582,8 +582,14 @@ pub fn render(doc: &Document, s: &WebSettings) -> std::result::Result Result { - let corners: Vec = p + const C: &str = "object.distort"; + // All four corners or nothing, each exactly [x, y]: nothing malformed is left out. + let corners: Option> = p .get("corners") .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|c| Some(Point::new(c.get(0)?.as_f64()?, c.get(1)?.as_f64()?))).collect()) - .unwrap_or_default(); - if corners.len() != 4 { - return Err(bad("object.distort", "corners must be 4 [x,y] points (TL, TR, BR, BL)")); + .filter(|a| a.len() == 4) + .and_then(|a| a.iter().map(|c| finite_numbers(c).map(|[x, y]| Point::new(x, y))).collect()); + let Some(&[tl, tr, br, bl]) = corners.as_deref() else { return Err(bad(C, "corners must be 4 [x, y] points (TL, TR, BR, BL)")) }; + let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)? } else { selected_roots(s)? }; + if ids.is_empty() { + return Err(bad(C, "nothing to distort")); } - let ids = match ids_param(p, "ids") { - Some(v) => v, - None => selected_roots(s)?, - }; - let src = match p.get("from").and_then(Value::as_array) { - Some(a) if a.len() == 4 => { - let f: Vec = a.iter().filter_map(Value::as_f64).collect(); - if f.len() != 4 { - return Err(bad("object.distort", "from must be [x0,y0,x1,y1]")); - } - Rect::new(f[0], f[1], f[2], f[3]) + let src = match p.get("from").filter(|v| !v.is_null()) { + None => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?, + Some(v) => { + let [x0, y0, x1, y1] = finite_numbers(v).ok_or_else(|| bad(C, "from must be [x0, y0, x1, y1]"))?; + Rect::new(x0, y0, x1, y1) } - _ => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("nothing to distort".into()))?, }; - if src.width().abs() < 1e-9 || src.height().abs() < 1e-9 { - return Err(EngineError::Other("cannot distort a zero-size bounding box".into())); + if !src.width().is_finite() || !src.height().is_finite() || src.width().abs() < 1e-9 || src.height().abs() < 1e-9 { + return Err(bad(C, "cannot distort a zero-size or non-finite bounding box")); } - let pr = Projective::from_rect(src, [corners[0], corners[1], corners[2], corners[3]]); + let pr = Projective::from_rect(src, [tl, tr, br, bl]); s.edit("Free Distort", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a0463b719..e49a8c91d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -346,6 +346,14 @@ impl DocState { // A sublayer takes new art only while it and the layers around it are shown and unlocked. self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer()) } + /// [`Self::insertion_parent`] for new art, which a locked or hidden layer never takes: an error + /// when no layer is shown and unlocked, so the art is refused rather than hidden or locked away. + pub fn target_parent(&self) -> Result> { + match self.insertion_parent() { + Some(p) if !self.doc.is_editable(p) => Err(EngineError::Other("the target layer is locked or hidden".into())), + parent => Ok(parent), + } + } /// The highlighted Layers panel rows that still exist (ids are reused after undo, so a /// remembered row must still be in the document). pub fn highlighted_rows(&self) -> Vec { @@ -1033,6 +1041,8 @@ impl Session { /// Add a document and make it active. pub fn add_document(&mut self, mut doc: Document, path: Option) -> usize { Self::refresh_text_bounds(&mut doc); + // Guides from files saved before guides had layers go on a layer. + doc.adopt_guides(); self.reset_tool_for_doc_switch(); let mut st = DocState::new(doc, path); st.history.limit = self.prefs.history_states as usize; @@ -1048,6 +1058,7 @@ impl Session { return false; } Self::refresh_text_bounds(&mut doc); + doc.adopt_guides(); if self.active == Some(index) { // Pending tool work (typing, a drag) belongs to the content being thrown away. self.reset_tool_for_doc_switch(); @@ -1313,6 +1324,11 @@ impl Session { { let st = self.doc_mut()?; let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) }; + // Undoing the previous preview counts as a change, so views redraw even when the command + // below returns without an edit. + if !Arc::ptr_eq(&st.doc, &it.doc) || st.selection != it.selection { + st.revision += 1; + } st.doc = it.doc.clone(); st.selection = it.selection.clone(); } @@ -1621,6 +1637,8 @@ mod tests_recolor; #[cfg(test)] mod tests_recovery; #[cfg(test)] +mod tests_reflecthit; +#[cfg(test)] mod tests_registration; #[cfg(test)] mod tests_save; @@ -1673,6 +1691,8 @@ mod tests_transparencygrid; #[cfg(test)] mod tests_typearea; #[cfg(test)] +mod tests_typescale; +#[cfg(test)] mod tests_units; #[cfg(test)] mod tests_variables; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ad04e948f..5b630f6f4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -52,6 +52,67 @@ fn group_ungroup() { assert_eq!(s.doc().unwrap().selection.len(), 2); } +/// Makes 10 pt squares at (0, 90) and (50, 100) into a compound path or group with the commands in +/// `make`, each run on the selection (the last one makes `outer`), draws a 10 pt square `r` at +/// (200, 0), selects the second square and `r`, and sets the selection's `key` to that square +/// → (session, key, outer, r). +fn key_inside(make: &[&str]) -> (Session, NodeId, NodeId, NodeId) { + let mut s = session(); + let a = rect(&mut s, 0.0, 90.0, 10.0, 10.0); + let key = rect(&mut s, 50.0, 100.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0, key.0]})).unwrap(); + let mut outer = key; + for cmd in make { + outer = NodeId(s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap()); + } + let r = rect(&mut s, 200.0, 0.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [key.0, r.0]})).unwrap(); + s.select(|_, sel| sel.key = Some(key)).unwrap(); + (s, key, outer, r) +} + +/// Aligns the selection left to its key object and, after Undo, spaces it 5 pt apart vertically +/// → the top-left corners of `outer` and `r` after each. +fn aligned_then_spaced(s: &mut Session, outer: NodeId, r: NodeId) -> [((f64, f64), (f64, f64)); 2] { + let at = |s: &Session, id| { + let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + (b.x0, b.y0) + }; + s.execute("object.align", &json!({"horizontal": "left", "bounds": "geometric"})).unwrap(); + let aligned = (at(s, outer), at(s, r)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 5, "bounds": "geometric"})).unwrap(); + [aligned, (at(s, outer), at(s, r))] +} + +/// Align and Distribute Spacing move a compound path as one object. With one of its members as the +/// key object, the compound path stays where it is: Align moves the others to the member's edge, +/// and Distribute Spacing spaces them from the compound path. +#[test] +fn a_compound_path_that_contains_the_key_object_stays_in_place() { + let (mut s, key, outer, r) = key_inside(&["object.compoundPath.make"]); + assert_eq!(s.doc().unwrap().selection.key, Some(key)); + let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r); + assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "it stays, the other moved to the key"); + assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "5 pt apart, it stays"); +} + +/// The key object stays the key when the group that contains it is added to the selection, by +/// `select.add` or by the Group Selection tool's click on a selected member. Align and Distribute +/// Spacing move that group as one object, and the group stays where it is, also when the key is in +/// a group inside it. +#[test] +fn a_selected_group_that_contains_the_key_object_stays_in_place() { + for make in [&["object.group"][..], &["object.group", "object.group"]] { + let (mut s, key, outer, r) = key_inside(make); + s.execute("select.add", &json!({"ids": [outer.0]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.key, Some(key), "{make:?}"); + let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r); + assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "{make:?}: it stays, the other moved to the key"); + assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "{make:?}: 5 pt apart, it stays"); + } +} + #[test] fn arrange_order() { let mut s = session(); @@ -134,6 +195,74 @@ fn fill_and_stroke_commands() { s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap(); } +#[test] +fn stroke_rejects_invalid_dash_components_and_preserves_defaults_on_error() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 40.0, 30.0); + s.execute("stroke.set", &json!({"weight": 3, "dash": [6, 3]})).unwrap(); + let previous_weight = s.paint.stroke_width; + let previous_stroke = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned(); + + for params in [ + json!({"weight": 10, "dash": [6, "not a number", 3]}), + json!({"weight": 10, "ids": "not an array"}), + json!({"weight": 10, "item": "invalid"}), + json!({"weight": 10, "arrowAlign": "unknown"}), + ] { + assert!(s.execute("stroke.set", ¶ms).is_err(), "{params} must fail"); + assert_eq!(s.paint.stroke_width, previous_weight, "failed stroke edit changed new-art defaults"); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned(), previous_stroke); + } + + // A valid dash list still updates the selected stroke and new-art default width. + s.execute("stroke.set", &json!({"weight": 7, "dash": [4, 2]})).unwrap(); + assert_eq!(s.paint.stroke_width, 7.0); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().width, 7.0); + + // With nothing selected the panel sets up the next object drawn: a rejected edit doesn't. + s.execute("select.none", &json!({})).unwrap(); + let next = s.new_art(); + assert!(s.execute("stroke.set", &json!({"weight": 12, "dash": [2, 2], "arrowAlign": "unknown"})).is_err()); + assert_eq!(s.new_art(), next); + s.execute("stroke.set", &json!({"weight": 12, "dash": [2, 2]})).unwrap(); + let next = s.new_art(); + let stroke = next.stroke().unwrap(); + assert_eq!((stroke.width, stroke.dash.as_ref().map(|d| d.pattern.clone())), (12.0, Some(vec![2.0, 2.0]))); +} + +#[test] +fn rejected_paint_edits_leave_new_art_defaults_and_focus_unchanged() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + let defaults = s.paint.clone(); + let focused = s.fill_active; + let original_fill = s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint(); + let original_stroke = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_paint(); + + // Both shortcut commands used to change defaults before checking invalid ids. + for (command, params) in [ + ("paint.swap", json!({"ids": "not an array"})), + ("paint.default", json!({"ids": ["not an object id"]})), + ("paint.setFill", json!({"color": "#123456", "ids": [false]})), + ("paint.setStroke", json!({"color": "#123456", "ids": "not an array"})), + ("paint.setFill", json!({"color": "#123456", "item": "invalid"})), + ] { + assert!(s.execute(command, ¶ms).is_err(), "{command} should reject {params}"); + assert_eq!(s.paint, defaults, "{command} changed new-art defaults on failure"); + assert_eq!(s.fill_active, focused, "{command} changed the active proxy on failure"); + let appearance = &s.doc().unwrap().doc.node(id).unwrap().appearance; + assert_eq!(appearance.fill_paint(), original_fill); + assert_eq!(appearance.stroke_paint(), original_stroke); + } + + // Successful operations still update both the selected object and new-art defaults. + s.execute("paint.swap", &json!({})).unwrap(); + assert_eq!(s.paint.fill, defaults.stroke); + assert_eq!(s.paint.stroke, defaults.fill); +} + #[test] fn select_same_fill() { let mut s = session(); @@ -262,6 +391,426 @@ fn selection_tool_drag_is_one_undo_step() { assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0); } +/// How many steps there are to undo and to redo, and whether the document has unsaved changes. +fn history(s: &Session) -> (usize, usize, bool) { + let st = s.doc().unwrap(); + (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) +} + +/// Select `ids` with a move of them left to redo, and mark the document saved. +fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.doc_mut().unwrap().mark_saved(); +} + +/// An Align that moves nothing (one object on the artboard's left edge, a pair already aligned, a +/// pair that an earlier Align left a rounding error apart) records no undo step: the step to redo +/// stays, the document stays unmodified, and the journal, which the Actions panel records from, +/// still has the command. +#[test] +fn an_align_that_moves_nothing_records_no_undo_step() { + let mut s = session(); + let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; + let left = json!({"horizontal": "left"}); + let a = rect(&mut s, 0.0, 50.0, 40.0, 40.0); + select_with_a_step_to_redo(&mut s, &[a]); + let before = history(&s); + assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved"); + // Object › Align sends no `to`, and the Align panel aligns one object to the artboard. + for p in [left.clone(), json!({"horizontal": "left", "to": "artboard"})] { + s.execute("object.align", &p).unwrap(); + assert_eq!(history(&s), before, "one object and no key: {p}"); + assert_eq!(s.journal.last().unwrap(), &("object.align".to_string(), p)); + } + let b = rect(&mut s, 0.0, 200.0, 80.0, 20.0); + select_with_a_step_to_redo(&mut s, &[a, b]); + let before = history(&s); + s.execute("object.align", &left).unwrap(); + assert_eq!(history(&s), before, "a pair already aligned"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!((x0(&s, a), x0(&s, b)), (5.0, 5.0), "Redo brings back the move"); + // An Align that moves something is one undo step, and it clears Redo. + let c = rect(&mut s, 120.0, 300.0, 20.0, 20.0); + select_with_a_step_to_redo(&mut s, &[a, c]); + let undo = history(&s).0; + s.execute("object.align", &left).unwrap(); + assert_eq!(history(&s), (undo + 1, 0, true)); + assert_eq!(x0(&s, c), 5.0); + // Aligning a rectangle at 51.8 to one at 10.7 leaves it a rounding error off 10.7, and a + // second Align records no step. + let d = rect(&mut s, 10.7, 400.0, 20.0, 20.0); + let e = rect(&mut s, 51.8, 450.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [d.0, e.0]})).unwrap(); + s.execute("object.align", &left).unwrap(); + assert!(x0(&s, e) != 10.7 && (x0(&s, e) - 10.7).abs() < 1e-12, "{}", x0(&s, e)); + s.doc_mut().unwrap().mark_saved(); + let before = history(&s); + s.execute("object.align", &left).unwrap(); + assert_eq!(history(&s), before, "a pair the first Align aligned"); +} + +/// Distribute and Distribute Spacing over objects already evenly spaced move nothing and record no +/// undo step either. +#[test] +fn a_distribute_that_moves_nothing_records_no_undo_step() { + let mut s = session(); + // The rectangles are 20 pt wide and 30 pt apart. + let ids = [0.0, 50.0, 100.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0)); + select_with_a_step_to_redo(&mut s, &ids); + let before = history(&s); + let calls = [ + ("object.distribute", json!({"horizontal": "left"})), + ("object.distribute", json!({"horizontal": "center"})), + ("object.distribute", json!({"horizontal": "right"})), + ("object.distributeSpacing", json!({"axis": "horizontal"})), + ("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 30})), + ]; + for (id, p) in calls { + s.execute(id, &p).unwrap(); + assert_eq!(history(&s), before, "{id} {p}"); + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p)); + } + // Distribute Spacing keeps the key object still and spaces the others from it. + s.execute("select.key", &json!({"id": ids[1].0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.key, Some(ids[1])); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 30})).unwrap(); + assert_eq!(history(&s), before, "spaced 30 pt from the key object"); + // After the middle one moves 10 pt right, Distribute moves it back in one undo step. + s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap(); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap(); + let undo = history(&s).0; + s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap(); + assert_eq!(history(&s).0, undo + 1); + assert_eq!(s.doc().unwrap().doc.node(ids[1]).unwrap().geometric_bounds().unwrap().x0, 50.0); + // For rectangles 30.1 pt apart, the gap Distribute Spacing computes moves the third one by a + // rounding error. + let ids = [0.1, 50.2, 100.3].map(|x| rect(&mut s, x, 100.0, 20.0, 20.0)); + select_with_a_step_to_redo(&mut s, &ids); + let before = history(&s); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap(); + assert_eq!(history(&s), before, "30.1 pt apart"); + // Distributing these three leaves the middle one a rounding error off halfway between the + // others, and a second Distribute records no step. + let ids = [40.045, 433.482, 1935.587].map(|x| rect(&mut s, x, 200.0, 20.0, 20.0)); + s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap(); + s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = history(&s); + s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap(); + assert_eq!(history(&s), before, "three objects the first Distribute spaced"); +} + +/// Move by 0 pt, Rotate and Shear by 0°, Scale to 100%, an identity Transform, Set Bounds with the +/// current position and size, a Nudge of 0 (of objects, anchors or ruler guides), a Transform with +/// no object to transform and a Selection tool drag that ends where it started record no undo +/// step. Transform Again then repeats the identity, and a Nudge of 0 of a live rectangle's anchor +/// leaves the rectangle a live shape. +#[test] +fn a_transform_that_moves_nothing_records_no_undo_step() { + let mut s = session(); + // The Nudges of a guide and of anchors below use this ruler guide and this plain path. + s.execute("guide.add", &json!({"vertical": true, "pos": 600})).unwrap(); + let made = s.execute("path.create", &json!({"anchors": [{"x": 300, "y": 300}, {"x": 400, "y": 300}]})).unwrap(); + let path = NodeId(made["id"].as_u64().unwrap()); + let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0); + select_with_a_step_to_redo(&mut s, &[a]); + let before = history(&s); + let calls = [ + ("object.move", json!({"dx": 0, "dy": 0})), + ("object.rotate", json!({"angle": 0})), + ("object.scale", json!({"sx": 100})), + ("object.shear", json!({"angle": 0})), + ("object.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})), + ("object.nudge", json!({"dx": 0, "dy": 0})), + ("object.setBounds", json!({})), + ("object.transformAgain", json!({})), + ]; + for (id, p) in calls { + assert_eq!(s.execute(id, &p).unwrap(), json!({"ids": [a.0]}), "{id} {p}"); + assert_eq!(history(&s), before, "{id} {p}"); + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p)); + } + assert_eq!(s.doc().unwrap().last_transform, Some((Affine::IDENTITY, false))); + // A Nudge of 0 with an anchor of the rectangle direct-selected keeps it a live shape. + let is_live = |s: &Session| matches!(s.doc().unwrap().doc.node(a).unwrap().kind, NodeKind::Path { live: Some(_), .. }); + assert!(is_live(&s), "a rectangle is a live shape"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("object.nudge", &json!({"dx": 0, "dy": 0})).unwrap(); + assert!(is_live(&s), "a Nudge of 0 keeps it a live shape"); + assert_eq!(history(&s), before, "an anchor of the live rectangle, then object.nudge"); + // A Nudge of 0 with anchors direct-selected or with only a ruler guide selected, and a + // Transform with nothing selected or with ids that name no object, with or without a copy, + // record no undo step either. + let calls = [ + ("select.anchors", json!({"id": path.0, "anchors": [[0, 0]]}), "object.nudge", json!({"dx": 0, "dy": 0})), + ("guide.select", json!({"indexes": [0]}), "object.nudge", json!({"dx": 0, "dy": 0})), + ("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})), + ("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true})), + ("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999]})), + ("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true})), + ]; + for (select, sp, id, p) in calls { + s.execute(select, &sp).unwrap(); + s.execute(id, &p).unwrap(); + assert_eq!(history(&s), before, "{select} {sp}, then {id} {p}"); + } + // A copy of no object lists no copies and keeps the selection. + let p = json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true}); + assert_eq!(s.execute("object.transform", &p).unwrap(), json!({"ids": []})); + assert_eq!(s.doc().unwrap().selection.objects, [a]); + // A Selection tool drag that ends where it started records no undo step either. Back at the + // start, the revision advances, so views redraw the object where it was. + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let x0 = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0; + let revision = |s: &Session| s.doc().unwrap().revision; + s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 35.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 35.0), v).unwrap(); + assert_ne!(x0(&s), 10.0, "the preview moves it"); + let moved = revision(&s); + s.pointer(&PointerEvent::new(PointerKind::Drag, 30.0, 35.0), v).unwrap(); + assert_eq!(x0(&s), 10.0); + assert!(revision(&s) > moved, "views redraw it back at the start"); + s.pointer(&PointerEvent::new(PointerKind::Up, 30.0, 35.0), v).unwrap(); + assert_eq!(history(&s), before, "a drag back to the start"); + assert_eq!(s.journal.last().unwrap().0, "object.transform"); + // With a copy, a Move by 0 pt duplicates the object in one undo step. + let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len(); + let n = count(&s); + s.execute("object.move", &json!({"dx": 0, "dy": 0, "copy": true})).unwrap(); + assert_eq!(history(&s), (before.0 + 1, 0, true)); + assert_eq!(count(&s), n + 1); +} + +/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and +/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to +/// Current Layer on objects already on top of the current layer. +#[test] +fn an_arrange_that_changes_no_order_records_no_undo_step() { + let mut s = session(); + let ids = [0.0, 30.0, 60.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0)); + let order = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::>(); + let calls = [ + (vec![ids[2]], "object.arrange.bringToFront"), + (vec![ids[2]], "object.arrange.bringForward"), + (vec![ids[0]], "object.arrange.sendToBack"), + (vec![ids[0]], "object.arrange.sendBackward"), + (vec![ids[2]], "object.arrange.sendToCurrentLayer"), + (vec![ids[1], ids[2]], "object.arrange.bringToFront"), + (vec![ids[1], ids[2]], "object.arrange.bringForward"), + (vec![ids[0], ids[1]], "object.arrange.sendToBack"), + (vec![ids[0], ids[1]], "object.arrange.sendBackward"), + (vec![ids[1], ids[2]], "object.arrange.sendToCurrentLayer"), + ]; + for (sel, c) in calls { + select_with_a_step_to_redo(&mut s, &sel); + let before = history(&s); + s.execute(c, &json!({})).unwrap(); + assert_eq!(history(&s), before, "{c} {sel:?}"); + assert_eq!(order(&s), ids, "{c} {sel:?}"); + } + // Bring to Front on the back object is one undo step, and it clears Redo. + select_with_a_step_to_redo(&mut s, &[ids[0]]); + let undo = history(&s).0; + s.execute("object.arrange.bringToFront", &json!({})).unwrap(); + assert_eq!(history(&s), (undo + 1, 0, true)); + assert_eq!(order(&s), [ids[1], ids[2], ids[0]]); +} + +/// Ungroup, Release Compound Path and Release Clipping Mask with none of their objects selected, +/// and Expand Shape with no live shape selected, record no undo step and keep the selection and its +/// key object. +#[test] +fn ungroup_release_and_expand_shape_with_nothing_to_act_on_record_no_undo_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + // Expand Shape makes the two rectangles plain paths in one undo step. + let undo = history(&s).0; + s.execute("object.expandShape", &json!({})).unwrap(); + assert_eq!(history(&s).0, undo + 1); + select_with_a_step_to_redo(&mut s, &[a, b]); + s.execute("select.key", &json!({"id": a.0})).unwrap(); + let (before, selection) = (history(&s), s.doc().unwrap().selection.clone()); + assert_eq!(selection.key, Some(a)); + for c in ["object.ungroup", "object.compoundPath.release", "object.clippingMask.release", "object.expandShape"] { + s.execute(c, &json!({})).unwrap(); + assert_eq!(history(&s), before, "{c}"); + assert_eq!(s.doc().unwrap().selection, selection, "{c}"); + assert_eq!(s.journal.last().unwrap().0, c); + } + // Ungroup with a group selected is one undo step. + s.execute("object.group", &json!({})).unwrap(); + let undo = history(&s).0; + s.execute("object.ungroup", &json!({})).unwrap(); + assert_eq!(history(&s).0, undo + 1); +} + +/// Unlock All with nothing locked and Show All with nothing hidden record no undo step and keep the +/// selection. Lock with every selected object already locked and Hide with every one already +/// hidden record no undo step and deselect them. +#[test] +fn lock_hide_unlock_all_and_show_all_with_nothing_to_do_record_no_undo_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + select_with_a_step_to_redo(&mut s, &[a]); + let before = history(&s); + for c in ["object.unlockAll", "object.showAll"] { + assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 0}), "{c}"); + assert_eq!(history(&s), before, "{c}"); + assert_eq!(s.doc().unwrap().selection.objects, [a], "{c}"); + } + // With the object locked or hidden, each is one undo step that selects it. + for (off, on) in [("object.lock", "object.unlockAll"), ("object.hide", "object.showAll")] { + s.execute(off, &json!({})).unwrap(); + let undo = history(&s).0; + assert_eq!(s.execute(on, &json!({})).unwrap(), json!({"count": 1}), "{on}"); + assert_eq!(history(&s).0, undo + 1, "{on}"); + assert_eq!(s.doc().unwrap().selection.objects, [a], "{on}"); + } + // Object Properties locks (then hides) a new rectangle `b`, and `select.set` selects it. + // Lock (then Hide) deselects it and records no undo step. With `a` selected too, it is one + // undo step. + for (c, props) in [("object.lock", json!({"locked": true})), ("object.hide", json!({"visible": false}))] { + let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0); + s.execute("object.setProps", &props).unwrap(); + select_with_a_step_to_redo(&mut s, &[a]); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let before = history(&s); + s.execute(c, &json!({})).unwrap(); + assert_eq!(history(&s), before, "{c}"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "{c}"); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute(c, &json!({})).unwrap(); + assert_eq!(history(&s), (before.0 + 1, 0, true), "{c} with a"); + s.execute("edit.undo", &json!({})).unwrap(); + } +} + +/// Set Bounds with the current position and size records no undo step on a rotated object, with +/// and without Use Preview Bounds, and on an unrotated object with Use Preview Bounds. In these +/// cases computing the transform can leave a rounding error in it. +#[test] +fn set_bounds_with_the_current_position_and_size_records_no_undo_step() { + let mut s = session(); + let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0); + s.execute("stroke.set", &json!({"weight": 5})).unwrap(); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + let c = rect(&mut s, 0.1, 0.05, 10.1, 6.06); + s.execute("stroke.set", &json!({"weight": 0.3})).unwrap(); + select_with_a_step_to_redo(&mut s, &[a]); + let before = history(&s); + for (id, preview) in [(a, false), (a, true), (c, true)] { + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("prefs.set", &json!({"key": "usePreviewBounds", "value": preview})).unwrap(); + // The Transform panel's values: X and Y place the reference point, W and H are the sides. + let b = s.transform_box(&[id]).unwrap(); + let (rp, w, h) = (b.reference_point(4), b.rect.width(), b.rect.height()); + for p in [json!({}), json!({"x": rp.x, "y": rp.y, "width": w, "height": h}), json!({"width": w, "proportional": true})] { + assert_eq!(s.execute("object.setBounds", &p).unwrap(), json!({"ids": [id.0]}), "{p}"); + assert_eq!(history(&s), before, "{id:?}, Use Preview Bounds {preview}: {p}"); + assert_eq!(s.journal.last().unwrap().0, "object.setBounds"); + } + } + // A new width is one undo step, and it clears Redo. + s.execute("object.setBounds", &json!({"width": 50})).unwrap(); + assert_eq!(history(&s), (before.0 + 1, 0, true)); +} + +/// Object Properties with the values the objects already have records no undo step: every +/// property, data already set or already absent, and no object. A value that differs on one of +/// the objects is one undo step, and an id that names no object is still an error. +#[test] +fn object_properties_already_set_record_no_undo_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0); + let set = json!({"ids": [a.0], "name": "A", "opacity": 50, "blend": "Multiply", "knockout": "on", "data": {"pivot": "1,2"}}); + s.execute("object.setProps", &set).unwrap(); + select_with_a_step_to_redo(&mut s, &[a, b]); + let before = history(&s); + let defaults = json!({ + "id": b.0, "name": "", "visible": true, "locked": false, "opacity": 100, "blend": "Normal", "isolate": false, + "knockout": "neutral", "knockoutShape": false, "data": {"pivot": null} + }); + let calls = + [set, defaults, json!({"id": a.0, "data": {"data-pivot": "1,2", "other": null}}), json!({"visible": true}), json!({}), json!({"ids": []})]; + for p in calls { + s.execute("object.setProps", &p).unwrap(); + assert_eq!(history(&s), before, "{p}"); + assert_eq!(s.journal.last().unwrap(), &("object.setProps".to_string(), p)); + } + // `a` already has 50% opacity, and `b` does not. + s.execute("object.setProps", &json!({"opacity": 50})).unwrap(); + assert_eq!(history(&s), (before.0 + 1, 0, true)); + assert!(s.execute("object.setProps", &json!({"ids": [999999], "visible": true})).is_err()); +} + +/// Live Shape Properties that a shape already has record no undo step: a polygon's sides, angle, +/// radius, side length and equal sides, an ellipse's pie, Invert Pie on a whole ellipse, and the +/// radius and kind its corners have (given corners, the Direct-Selected one, or all). A value that +/// differs is one undo step. Setting the radius of a rectangle that kept a scale in its transform +/// is one undo step too, because it folds the scale into the rectangle's size. +#[test] +fn live_shape_properties_already_set_record_no_undo_step() { + let mut s = session(); + let id = |v: Value| NodeId(v["id"].as_u64().unwrap()); + let r = rect(&mut s, 0.0, 0.0, 100.0, 60.0); + let polygon = id(s.execute("shape.polygon", &json!({"cx": 300, "cy": 100, "radius": 50, "sides": 6})).unwrap()); + let ellipse = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 80, "height": 40})).unwrap()); + let star = id(s.execute("shape.star", &json!({"cx": 300, "cy": 300, "radius1": 60, "radius2": 30})).unwrap()); + // The rectangle's corners get 8 pt radii, its top-right corner gets 12 pt, and the polygon gets + // 30 pt sides. + for p in [json!({"id": r.0, "radius": 8}), json!({"id": r.0, "corners": [1], "radius": 12}), json!({"id": polygon.0, "sideLength": 30})] { + s.execute("object.setLiveShape", &p).unwrap(); + } + let NodeKind::Path { live: Some(l), .. } = &s.doc().unwrap().doc.node(polygon).unwrap().kind else { panic!("not a live polygon") }; + let (angle, radius) = (l.polygon_angle().unwrap(), l.polygon_radius().unwrap()); + select_with_a_step_to_redo(&mut s, &[r, polygon, ellipse, star]); + let before = history(&s); + let calls = [ + json!({"id": polygon.0, "sides": 6, "polygonAngle": angle, "polygonRadius": radius, "makeSidesEqual": true}), + json!({"id": polygon.0, "sideLength": 30, "kind": "round"}), + json!({"id": ellipse.0, "pieStart": 0, "pieEnd": 360}), + json!({"id": ellipse.0, "invertPie": true}), + json!({"id": r.0, "corners": [0, 2, 3], "radius": 8, "kind": "round"}), + json!({"items": [{"id": r.0, "corners": [1]}], "radius": 12}), + json!({"id": star.0, "radius": 0}), + ]; + for p in calls { + s.execute("object.setLiveShape", &p).unwrap(); + assert_eq!(history(&s), before, "{p}"); + assert_eq!(s.journal.last().unwrap(), &("object.setLiveShape".to_string(), p)); + } + // With an anchor of the top-left corner direct-selected, the radius applies to that corner. + s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("object.setLiveShape", &json!({"radius": 8})).unwrap(); + assert_eq!(history(&s), before, "the Direct-Selected corner"); + for p in [json!({"id": polygon.0, "sides": 7}), json!({"id": ellipse.0, "pieEnd": 90}), json!({"radius": 9})] { + let undo = history(&s).0; + s.execute("object.setLiveShape", &p).unwrap(); + assert_eq!(history(&s).0, undo + 1, "{p}"); + } + // The rectangle is now 50 × 60 pt with 10 pt corners, stretched to 100 × 60 pt by its + // transform, as files from before #291 saved it. Setting the radius its corners show makes the + // corners circles. + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") }; + let xf = Affine::scale_non_uniform(2.0, 1.0); + *live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + *path = live.to_path(); + } + let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0); + let undo = history(&s).0; + s.execute("object.setLiveShape", &json!({"id": r.0, "radius": shown})).unwrap(); + assert_eq!(history(&s).0, undo + 1, "a stretched rectangle"); +} + #[test] fn rectangle_tool_draws() { let mut s = session(); @@ -892,3 +1441,66 @@ fn given_targets_are_used_as_given_and_range_style_takes_the_selected_type() { assert!(s.execute("text.setRangeStyle", &json!({"start": 0, "end": 3, "size": 40})).is_err(), "two type objects: which one?"); assert!(s.execute("text.setRangeStyle", &json!({"id": "two", "size": 40})).is_err()); } + +#[test] +fn malformed_anchor_selection_never_replaces_or_partially_changes_selection() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 30.0, 20.0); + let b = rect(&mut s, 50.0, 10.0, 30.0, 20.0); + let expected = s.doc().unwrap().selection.objects.clone(); + let expected_anchors = s.doc().unwrap().selection.anchors.clone(); + for p in [ + json!({"id": a.0, "anchors": [[0, 0], ["bad", 2]]}), + json!({"id": a.0, "anchors": [[0, 0, 1]]}), + json!({"id": a.0, "anchors": "not an array"}), + json!({"id": u64::MAX, "anchors": []}), + json!({"id": a.0, "anchors": [], "mode": "missing"}), + ] { + assert!(s.execute("select.anchors", &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().selection.objects, expected); + assert_eq!(s.doc().unwrap().selection.anchors, expected_anchors); + } + for p in [ + json!({"items": [{"id": a.0, "anchors": [[0, 0]]}, {"id": b.0, "anchors": [[0, "bad"]]}]}), + json!({"items": [{"id": a.0, "anchors": [[0, 0]]}, {"id": "bad", "anchors": [[0, 0]]}]}), + json!({"items": [{"id": u64::MAX, "anchors": []}]}), + json!({"items": [{"id": a.0}]}), + ] { + assert!(s.execute("select.anchorsMany", &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().selection.objects, expected); + assert_eq!(s.doc().unwrap().selection.anchors, expected_anchors); + } + s.execute("select.anchorsMany", &json!({"items": [{"id": a.0, "anchors": [[0, 0]]}]})).unwrap(); + assert!(s.doc().unwrap().selection.contains(a)); + assert!(!s.doc().unwrap().selection.contains(b)); +} + +#[test] +fn path_commands_reject_bad_anchors_before_modifying_geometry() { + let mut s = session(); + let a = rect(&mut s, 10.0, 20.0, 50.0, 30.0); + let original_points: Vec<_> = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect(); + let history = s.doc().unwrap().history.undo.len(); + let valid = json!({"x": 10, "y": 20}); + for invalid in [ + json!({"x": "bad", "y": 40}), + json!({"x": 50}), + json!({"x": 50, "y": 40, "in": [0]}), + json!({"x": 50, "y": 40, "out": [1, "bad"]}), + json!({"x": 50, "y": 40, "smooth": "yes"}), + ] { + let anchors = json!([valid.clone(), invalid]); + assert!(s.execute("path.create", &json!({"anchors": anchors})).is_err(), "{anchors}"); + assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": anchors}]})).is_err(), "{anchors}"); + let points: Vec<_> = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect(); + assert_eq!(points, original_points); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + } + assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": []}, {"closed": true}]})).is_err()); + assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": [valid.clone()], "closed": "yes"}]})).is_err()); + // The Pen's append reads its anchor the same way. + assert!(s.execute("path.appendAnchor", &json!({"id": a.0, "x": 5, "y": 5, "out": [1]})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": [{"x": 0, "y": 0}, {"x": 30, "y": 40}], "closed": false}]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 2); +} diff --git a/crates/engine/src/tests_assets.rs b/crates/engine/src/tests_assets.rs index 2a674b156..79c11c997 100644 --- a/crates/engine/src/tests_assets.rs +++ b/crates/engine/src/tests_assets.rs @@ -265,3 +265,21 @@ fn assets_export_into_a_folder() { assert!(dir.join("2x/Logo@2x.png").is_file() && dir.join("PDF/Logo.pdf").is_file()); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn collecting_explicit_assets_rejects_invalid_ids_without_adding_anything() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0); + let original_selection = s.doc().unwrap().selection.objects.clone(); + let before = undo_len(&s); + for ids in [json!([a, "not an object"]), json!([a, 3.5]), json!([a, u64::MAX]), json!({"id": a})] { + assert!(s.execute("assets.add", &json!({"ids": ids})).is_err(), "invalid ids: {ids}"); + assert!(assets(&mut s).is_empty(), "invalid ids must not collect only the valid subset"); + assert_eq!(undo_len(&s), before); + assert_eq!(s.doc().unwrap().selection.objects, original_selection); + } + // Valid ids still collect art in document paint order. + assert_eq!(run(&mut s, "assets.add", json!({"ids": [b, a]}))["added"], 2); + assert_eq!(assets(&mut s).len(), 2); +} diff --git a/crates/engine/src/tests_cut.rs b/crates/engine/src/tests_cut.rs index 7bda31d8d..a0a0d2a7b 100644 --- a/crates/engine/src/tests_cut.rs +++ b/crates/engine/src/tests_cut.rs @@ -118,6 +118,24 @@ fn line_cut_that_misses_changes_nothing() { assert!(s.execute("path.lineCut", &json!({"from": [5, 5]})).is_err()); } +#[test] +fn rect_cut_rejects_non_numeric_rectangle_elements_without_modifying_art() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + select(&mut s, &[id]); + let original = bounds(&s, id); + let history = undo_depth(&s); + // The old parser dropped the string and used the four remaining values. + for params in [json!([0, "ignored", 10, 50, 50]), json!([0, 10, 50, null, 50]), json!([0, 10, 50, 50, 5])] { + assert!(s.execute("path.rectCut", &json!({"rect": params})).is_err()); + assert!(near(bounds(&s, id), original), "failed cut must not change geometry"); + assert_eq!(undo_depth(&s), history, "failed cut must not add undo history"); + } + // Proper rectangular cuts still work. + s.execute("path.rectCut", &json!({"rect": [10, 10, 70, 70]})).unwrap(); + assert!(near(bounds(&s, id), Rect::new(10.0, 10.0, 80.0, 80.0))); +} + #[test] fn rect_cut_crops_to_real_geometry() { let mut s = session(); diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 514ad9532..7eea62a8d 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -870,6 +870,10 @@ fn the_pen_continues_any_open_path_and_joins_another() { assert_eq!(s.doc().unwrap().selection.objects, vec![a]); assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::PenJoin); assert_eq!(s.cursor(Point::new(350.0, 200.0), none, v), Cursor::Pen, "the middle of the other path"); + // #1009: Use Precise Cursors makes the join pointer a crosshair too. + s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap(); + assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::Crosshair); + s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap(); assert_eq!(gesture(&mut s, &[(300.0, 200.0)], none), 1); let pts = |s: &Session, id| path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|x| (x.p.x, x.p.y))).collect::>(); assert_eq!(pts(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 200.0), (400.0, 200.0)]); diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests_editcolors.rs index c3413d120..93ef8faad 100644 --- a/crates/engine/src/tests_editcolors.rs +++ b/crates/engine/src/tests_editcolors.rs @@ -3,8 +3,10 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::Node; +use vectorcraft_tools::{PointerEvent, PointerKind}; use super::*; +use crate::tooling::ViewInfo; fn session() -> Session { let mut s = Session::new(); @@ -99,6 +101,38 @@ fn edit_colors_reach_mesh_points() { assert_eq!(mesh_colors(&s, m), pts, "a mesh's points are fill colours"); } +/// Clicking a gradient mesh's point with the Mesh tool or Direct Selection picks it: a solid fill +/// colour then recolours that point alone (#1050). +#[test] +fn a_clicked_mesh_point_takes_the_fill_colour() { + let mut s = session(); + let m = mesh(&mut s); + let before = mesh_colors(&s, m); + let v = ViewInfo::default(); + let click = |s: &mut Session, i: usize| { + let NodeKind::Mesh(g) = &s.doc().unwrap().doc.node(m).unwrap().kind else { panic!("not a mesh") }; + let q = g.points[i].p; + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, q.x, q.y), v).unwrap(); + } + }; + // The Mesh tool selects the mesh whose point it clicks. + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("mesh", v).unwrap(); + click(&mut s, 1); + assert_eq!(s.doc().unwrap().selection.objects, [m]); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + let after = mesh_colors(&s, m); + assert_eq!(after[1].to_hex(), "#0000ff", "the clicked point takes the colour"); + assert_eq!((after[0], after[2], after[3]), (before[0], before[2], before[3]), "the other points keep theirs"); + s.select_tool("directSelection", v).unwrap(); + click(&mut s, 2); + s.execute("paint.setFill", &json!({"color": "#ffff00"})).unwrap(); + let after = mesh_colors(&s, m); + assert_eq!((after[1].to_hex(), after[2].to_hex()), ("#0000ff".to_string(), "#ffff00".to_string())); + assert_eq!((after[0], after[3]), (before[0], before[3])); +} + #[test] fn blend_moves_a_mesh_to_the_blended_colour() { let mut s = session(); diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 6ad69cf3e..20e73d962 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -78,6 +78,36 @@ fn art_in_a_sublayer_is_selected_as_objects_not_the_sublayer() { assert!(s.doc().unwrap().doc.node(a).is_none()); } +/// #1002: with no layer shown and unlocked, new art is refused (the document unchanged) rather +/// than put on a hidden or locked layer; another layer that takes art still gets it. +#[test] +fn new_art_is_refused_when_no_layer_is_shown_and_unlocked() { + for prop in ["visible", "locked"] { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id; + let copied = rect(&mut s, 0.0); + run(&mut s, "select.set", json!({"ids": [copied.0]})); + run(&mut s, "edit.copy", json!({})); + run(&mut s, "select.none", json!({})); + run(&mut s, "layer.setProps", json!({"ids": [layer.0], prop: prop == "locked"})); + let before = s.doc().unwrap().doc.clone(); + for (cmd, p) in [ + ("shape.ellipse", json!({"x": 10, "y": 10, "width": 20, "height": 20})), + ("shape.polarGrid", json!({"x": 10, "y": 10, "width": 20, "height": 20})), + ("text.create", json!({"x": 10, "y": 40, "text": "A"})), + ("edit.paste", json!({})), + ] { + let e = s.execute(cmd, &p).unwrap_err(); + assert_eq!(e.to_string(), "the target layer is locked or hidden", "{prop} {cmd}"); + } + assert_eq!(s.doc().unwrap().doc.layers, before.layers, "{prop}: nothing was added"); + let other = id(&run(&mut s, "layer.new", json!({}))); + run(&mut s, "layer.setCurrent", json!({"id": layer.0})); + let n = rect(&mut s, 50.0); + assert_eq!(parent(&s, n), Some(other), "{prop}"); + } +} + #[test] fn clicking_rows_at_any_depth_highlights_them_and_sets_the_current_layer() { let mut s = session(); diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 2d92bb2bc..04f1e68a0 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -785,7 +785,11 @@ fn ruler_guides_select_move_and_delete() { assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([0])); assert!(selected(&s).is_empty()); assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1])); - assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1], json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true})); + let layer = s.doc().unwrap().current_layer().unwrap().0; + assert_eq!( + s.execute("guide.list", &json!({})).unwrap()[1], + json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true, "layer": layer, "shown": true, "editable": true}) + ); assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err()); // A move takes each selected guide along its own axis, in one undo step. let n = undo_len(&s); @@ -823,6 +827,40 @@ fn ruler_guides_select_move_and_delete() { assert!(matches!(s.execute("edit.clear", &json!({})), Err(EngineError::Disabled(..)))); } +/// Shift selects ruler guides and art together, in either order; Delete takes both in one step. +#[test] +fn guides_and_art_are_shift_selected_together() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 40.0, 20.0); + let b = rect(&mut s, 70.0, 50.0, 60.0, 30.0); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + // Art first, then a Shift-click on a guide: the art stays selected. + sel(&mut s, &[a]); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + assert_eq!((selected(&s), selected_guides(&s)), (vec![a], vec![0])); + // A guide first, then Shift-added art: the guide stays selected. + s.execute("guide.select", &json!({"indexes": [1]})).unwrap(); + s.execute("select.add", &json!({"ids": [b.0]})).unwrap(); + assert_eq!((selected(&s), selected_guides(&s)), (vec![b], vec![1])); + // A new selection drops them. + sel(&mut s, &[a]); + assert!(selected_guides(&s).is_empty()); + // Delete takes the art and the guides, in one step; undo brings both back. + s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap(); + let n = undo_len(&s); + s.execute("edit.clear", &json!({})).unwrap(); + assert_eq!((guides(&s), undo_len(&s)), (vec![(true, 200.0)], n + 1)); + assert!(s.doc().unwrap().doc.node(a).is_none() && s.doc().unwrap().doc.node(b).is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 2); + // Ids given: only those go, the guides stay. + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + s.execute("select.add", &json!({"ids": [b.0]})).unwrap(); + s.execute("edit.clear", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(guides(&s).len(), 2); +} + /// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved, /// copied and deleted with it; canvas guides stay put. #[test] @@ -832,11 +870,12 @@ fn artboard_guides_go_with_their_artboard() { s.execute("guide.add", &json!({"vertical": true, "pos": 1000, "artboard": 1})).unwrap(); s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap(); let list = |s: &mut Session| s.execute("guide.list", &json!({})).unwrap(); + let layer = s.doc().unwrap().current_layer().unwrap().0; assert_eq!( list(&mut s), json!([ - {"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1}, - {"index": 1, "vertical": false, "pos": 100.0, "selected": false}, + {"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true}, + {"index": 1, "vertical": false, "pos": 100.0, "selected": false, "layer": layer, "shown": true, "editable": true}, ]) ); let spans = |s: &Session| { @@ -875,7 +914,10 @@ fn artboard_guides_go_with_their_artboard() { let n = undo_len(&s); s.execute("artboard.delete", &json!({"index": 0})).unwrap(); assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1])); - assert_eq!(list(&mut s)[0], json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true})); + assert_eq!( + list(&mut s)[0], + json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true, "layer": layer, "shown": true, "editable": true}) + ); assert_eq!(undo_len(&s), n + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!((guides(&s).len(), selected_guides(&s)), (4, vec![1, 2])); @@ -917,12 +959,120 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() { s.set_tool_option("active", &json!(1)); s.ruler_guide(false, &ev(PointerKind::Drag, 1000.0, 50.0), true, v).unwrap(); s.ruler_guide(false, &ev(PointerKind::Up, 1000.0, 50.0), true, v).unwrap(); + let layer = s.doc().unwrap().current_layer().unwrap().0; assert_eq!( s.execute("guide.list", &json!({})).unwrap()[1], - json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1}) + json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true}) ); } +/// Guides saved before guides had layers are put on a layer when the document opens. +#[test] +fn guides_from_older_files_are_put_on_a_layer() { + let mut s = Session::new(); + let mut d = vectorcraft_doc::Document::new(400.0, 300.0); + d.guides.push(vectorcraft_doc::Guide::new(true, 50.0)); + d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + s.add_document(d, None); + let st = s.doc().unwrap(); + let top = st.doc.default_layer(); + assert!(top.is_some() && st.doc.guides.iter().all(|g| g.layer == top), "{:?}", st.doc.guides); +} + +/// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it, +/// and moved to another layer. +#[test] +fn ruler_guides_live_on_layers() { + let mut s = session(); + let first = s.doc().unwrap().current_layer().unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[0].layer, Some(first), "on the current layer"); + let second = id_of(&s.execute("layer.new", &json!({"name": "Guides"})).unwrap()); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(second), "the new layer is current"); + assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 1, "layer": 99999})).is_err()); + // Hidden or locked with its layer. + s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1]["shown"], false); + s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap(); + let row = s.execute("guide.list", &json!({})).unwrap()[1].clone(); + assert_eq!((row["shown"].clone(), row["editable"].clone()), (json!(true), json!(false))); + s.execute("layer.setProps", &json!({"ids": [second.0], "locked": false})).unwrap(); + // Moved to the first layer, then back. + s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first)); + s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap(); + assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err()); + // Several at once, as dragging their rows onto a layer's row does. + s.execute("guide.setLayer", &json!({"indexes": [0, 1], "layer": first.0})).unwrap(); + assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.layer == Some(first))); + s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap(); + assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err()); + // Deleting a layer deletes its guides; undo brings them back. + s.execute("layer.delete", &json!({"ids": [second.0]})).unwrap(); + assert_eq!(guides(&s), [(true, 200.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 2); +} + +/// Guides on a hidden or locked layer leave the selection and can't be moved, deleted or moved to +/// another layer; new guides skip a locked current layer as new art does; no guide goes onto a +/// locked layer. +#[test] +fn guides_on_hidden_or_locked_layers_are_left_alone() { + let mut s = session(); + let first = s.doc().unwrap().current_layer().unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + let second = id_of(&s.execute("layer.new", &json!({})).unwrap()); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0, 1]})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [first.0], "locked": true})).unwrap(); + assert_eq!(selected_guides(&s), [1], "the locked layer's guide is deselected"); + assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([]), "and can't be selected"); + assert!(s.execute("guide.move", &json!({"index": 0, "pos": 50})).is_err()); + assert!(s.execute("guide.remove", &json!({"index": 0})).is_err()); + assert!(s.execute("guide.setLayer", &json!({"index": 0, "layer": second.0})).is_err()); + assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).is_err(), "not onto a locked layer"); + s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap(); + assert!(selected_guides(&s).is_empty(), "the hidden layer's guide is deselected"); + // Both layers hidden or locked: a new guide is refused, as new art is. + assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 10})).is_err()); + s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [first.0], "locked": false})).unwrap(); + // The current layer locked: the new guide goes on the top shown, unlocked layer. + s.execute("guide.add", &json!({"vertical": true, "pos": 20})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[2].layer, Some(first)); + assert_eq!(guides(&s).len(), 3); +} + +/// Guides follow their layers through Duplicate, Merge Selected and Flatten Artwork, in one undo +/// step each. +#[test] +fn guides_go_with_their_layers_when_duplicated_merged_and_flattened() { + let mut s = session(); + let first = s.doc().unwrap().current_layer().unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + let second = id_of(&s.execute("layer.new", &json!({})).unwrap()); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).map(|(_, g)| (g.vertical, g.pos)).collect::>(); + // Duplicate Layer copies its guides onto the copy. + let copy = s.execute("layer.duplicate", &json!({"ids": [second.0]})).unwrap()["ids"][0].as_u64().map(NodeId).unwrap(); + assert_eq!((on(&s, second), on(&s, copy)), (vec![(false, 300.0)], vec![(false, 300.0)])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 2); + // Merged into the first layer, the second layer's guide goes with its art. + s.execute("layer.merge", &json!({"ids": [second.0, first.0]})).unwrap(); + assert_eq!(on(&s, first), [(true, 200.0), (false, 300.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((on(&s, first), on(&s, second)), (vec![(true, 200.0)], vec![(false, 300.0)])); + // Flattened into the first layer, a hidden layer is discarded with its guides. + s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap(); + s.execute("layer.flatten", &json!({"id": first.0})).unwrap(); + assert_eq!(guides(&s), [(true, 200.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 2); +} + /// #451: art moved with the Selection tool lands flush on the artboard's edges and centre (Smart /// Guides). #[test] diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index 1844a8100..fc2c4e6aa 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -56,6 +56,13 @@ fn edit_gradient_angle_reverse_and_errors() { s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap(); let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() }; assert_eq!(g.gradient.stops[0].color.to_hex(), "#000000"); + // #1007: an off-centre midpoint is mirrored, not reset. + let stops = json!([{"offset": 0, "color": "#ff0000", "midpoint": 0.2}, {"offset": 1, "color": "#0000ff"}]); + s.execute("paint.editGradient", &json!({"stroke": false, "stops": stops})).unwrap(); + s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap(); + let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() }; + assert_eq!(g.gradient.stops[0].color.to_hex(), "#0000ff"); + assert!((g.gradient.stops[0].midpoint - 0.8).abs() < 1e-6, "{:?}", g.gradient.stops); assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#fff"}]})).is_err()); assert!(s.execute("paint.editGradient", &json!({"kind": "conic"})).is_err()); } diff --git a/crates/engine/src/tests_place.rs b/crates/engine/src/tests_place.rs index aba974e5b..0fb2c5d74 100644 --- a/crates/engine/src/tests_place.rs +++ b/crates/engine/src/tests_place.rs @@ -57,6 +57,37 @@ fn serialize(s: &mut Session, format: &str) -> Vec { vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() } +#[test] +fn malformed_place_coordinates_are_rejected_instead_of_silently_changed() { + let mut s = session(); + let png = png(4, 2, [20, 40, 60, 255], None); + let original = s.doc().unwrap().doc.layers.clone(); + // Previously filter_map dropped the string from a five-element rectangle and + // treated the remaining four values as a different, valid placement. + for rect in [json!([0, "ignored", 10, 40, 50]), json!([10, 20, 40, null, 50]), json!([10, 20, 40, 50, 60])] { + let mut params = file("tiny.png", &png); + params["rect"] = rect; + assert!(s.execute("file.place", ¶ms).is_err(), "{params}"); + assert_eq!(s.doc().unwrap().doc.layers, original, "failed Place must not add art"); + } + // An explicitly supplied invalid position formerly behaved as though no + // position were supplied and centred the image on the artboard. + for at in [json!(["not-a-number", 20]), json!([10, null]), json!([10, 20, 30]), json!("invalid")] { + let mut params = file("tiny.png", &png); + params["at"] = at; + assert!(s.execute("file.place", ¶ms).is_err(), "{params}"); + assert_eq!(s.doc().unwrap().doc.layers, original); + } + let mut params = file("tiny.png", &png); + params["rect"] = json!([10, 20, 80, 60]); + let placed = s.execute("file.place", ¶ms).unwrap(); + assert_eq!(placed["ids"].as_array().unwrap().len(), 1); + // A null position is no position, as for the other optional params. + let mut params = file("tiny.png", &png); + params["at"] = Value::Null; + assert_eq!(s.execute("file.place", ¶ms).unwrap()["ids"].as_array().unwrap().len(), 1); +} + #[test] fn a_300_px_png_at_300_ppi_places_as_72_pt_centred_selected_and_undoable() { let mut s = session(); diff --git a/crates/engine/src/tests_rasterfilters.rs b/crates/engine/src/tests_rasterfilters.rs index a32bd7094..90f6d2b5c 100644 --- a/crates/engine/src/tests_rasterfilters.rs +++ b/crates/engine/src/tests_rasterfilters.rs @@ -1,5 +1,5 @@ -//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Distort › -//! Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture › +//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes, +//! Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture › //! Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, Video): applied and //! edited as commands, listed in the catalogue, drawn on the canvas, and written to PDF and SVG //! as images of the effected object. @@ -18,6 +18,18 @@ const EFFECTS: [(&str, &str); 3] = [ ("sharpen.unsharpMask", r#"{"amount": 300, "radius": 3}"#), ]; +/// Effect › Brush Strokes, at their defaults. +const BRUSH_STROKES: [(&str, &str); 8] = [ + ("brushStrokes.accentedEdges", "{}"), + ("brushStrokes.angledStrokes", "{}"), + ("brushStrokes.crosshatch", "{}"), + ("brushStrokes.darkStrokes", "{}"), + ("brushStrokes.inkOutlines", "{}"), + ("brushStrokes.spatter", "{}"), + ("brushStrokes.sprayedStrokes", "{}"), + ("brushStrokes.sumiE", "{}"), +]; + /// Effect › Distort (Diffuse Glow with its highlights glowing from a low brightness up, as the /// striped art's reds are dark). const DISTORT: [(&str, &str); 3] = @@ -78,6 +90,7 @@ fn listed_applied_edited_and_undone() { EFFECTS.iter().map(|(id, _)| list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap()["menu"].clone()).collect(); assert_eq!(menus, [json!(["Effect", "Blur"]), json!(["Effect", "Blur"]), json!(["Effect", "Sharpen"])]); for (group, menu) in [ + (BRUSH_STROKES.as_slice(), "Brush Strokes"), (DISTORT.as_slice(), "Distort"), (PIXELATE.as_slice(), "Pixelate"), (TEXTURE.as_slice(), "Texture"), @@ -108,7 +121,7 @@ fn listed_applied_edited_and_undone() { #[test] fn each_effect_changes_the_canvas_and_draws_the_same_twice() { - for (id, params) in EFFECTS.into_iter().chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) { + for (id, params) in EFFECTS.into_iter().chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) { let (mut s, g) = striped(); let plain = render(&s); let params: Value = serde_json::from_str(params).unwrap(); @@ -137,7 +150,7 @@ fn spin_blur_reaches_past_the_corners() { #[test] fn pdf_and_svg_write_the_effected_object_as_an_image() { - for (id, params) in EFFECTS.into_iter().chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) { + for (id, params) in EFFECTS.into_iter().chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) { let (mut s, g) = striped(); let params: Value = serde_json::from_str(params).unwrap(); s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap(); diff --git a/crates/engine/src/tests_reflecthit.rs b/crates/engine/src/tests_reflecthit.rs new file mode 100644 index 000000000..89d1c3272 --- /dev/null +++ b/crates/engine/src/tests_reflecthit.rs @@ -0,0 +1,153 @@ +//! A reflected object is still clicked by its fill, not only by its outline. + +use serde_json::{Value, json}; +use vectorcraft_geom::Point; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A click with the Selection tool at (x, y): what it selects. +fn click(s: &mut Session, x: f64, y: f64) -> Vec { + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), v).unwrap(); + } + s.doc().unwrap().selection.objects.clone() +} + +#[test] +fn a_reflected_shape_is_clicked_by_its_fill() { + const HEART: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z"; + let makers: [(&str, Value); 5] = [ + ("shape.rectangle", json!({"x": 150, "y": 150, "width": 100, "height": 100})), + ("shape.ellipse", json!({"x": 150, "y": 150, "width": 100, "height": 100})), + ("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 60, "sides": 5})), + ("shape.star", json!({"cx": 200, "cy": 200, "radius1": 70, "radius2": 40, "points": 5})), + ("path.create", json!({"d": HEART})), + ]; + for (cmd, params) in makers { + for axis in ["horizontal", "vertical"] { + let mut s = session(); + let id = id_of(&s.execute(cmd, ¶ms).unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.reflect", &json!({"axis": axis})).unwrap(); + let b = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + let c = b.center(); + assert_eq!(click(&mut s, c.x, c.y), vec![id], "{cmd} reflected {axis}: a click in its fill at {c:?} (bounds {b:?})"); + } + } +} + +/// Hearts as SVG files bring them: a plain path, a compound path, inside a group, under a +/// transform. Reflected with the button and with a bounding-box handle dragged across. +#[test] +fn a_reflected_imported_heart_is_clicked_by_its_fill() { + const D: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z"; + let files = [ + format!(r##""##), + format!( + r##""## + ), + format!(r##""##), + format!( + r##""## + ), + format!( + r##""## + ), + ]; + for (i, svg) in files.iter().enumerate() { + for how in ["horizontal", "vertical", "handle"] { + let mut s = Session::new(); + s.add_document(vectorcraft_svg::import(svg).unwrap(), None); + s.execute("select.all", &json!({})).unwrap(); + let ids = s.doc().unwrap().selection.objects.clone(); + assert!(!ids.is_empty(), "file {i}: nothing imported"); + let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap(); + if how == "handle" { + // Drag the top handle down past the bottom: a top-to-bottom flip. + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let (from, to) = (Point::new(b.center().x, b.y0), Point::new(b.center().x, b.y1 + b.height())); + for (kind, p) in + [(PointerKind::Down, from), (PointerKind::Drag, Point::new(from.x, b.center().y)), (PointerKind::Drag, to), (PointerKind::Up, to)] + { + s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap(); + } + } else { + s.execute("object.reflect", &json!({"axis": how})).unwrap(); + } + let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + // Inside the heart away from the compound's square hole: a third of the way down. + let c = Point::new(b.center().x, if how == "horizontal" || how == "handle" { b.y1 - b.height() * 0.3 } else { b.y0 + b.height() * 0.3 }); + let picked = click(&mut s, c.x, c.y); + assert_eq!(picked, ids, "file {i} flipped by {how}: a click in its fill at {c:?} (bounds {b:?})"); + } + } +} + +/// Still selected after the flip, a drag from inside the fill moves it. +#[test] +fn a_reflected_selected_shape_drags_by_its_fill() { + const HEART: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z"; + let makers: [(&str, Value); 4] = [ + ("shape.rectangle", json!({"x": 150, "y": 150, "width": 100, "height": 100})), + ("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 60, "sides": 3})), + ("shape.star", json!({"cx": 200, "cy": 200, "radius1": 70, "radius2": 40, "points": 5})), + ("path.create", json!({"d": HEART})), + ]; + for (cmd, params) in makers { + for axis in ["horizontal", "vertical"] { + let mut s = session(); + let id = id_of(&s.execute(cmd, ¶ms).unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.reflect", &json!({"axis": axis})).unwrap(); + let b = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + let c = b.center(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + for (kind, x) in + [(PointerKind::Down, c.x), (PointerKind::Drag, c.x + 20.0), (PointerKind::Drag, c.x + 40.0), (PointerKind::Up, c.x + 40.0)] + { + s.pointer(&PointerEvent::new(kind, x, c.y).with_mods(Mods::default()), v).unwrap(); + } + let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + assert!((after.x0 - b.x0 - 40.0).abs() < 1e-6, "{cmd} reflected {axis}: a drag from {c:?} left it at {after:?} (was {b:?})"); + } + } +} + +/// An open path (an anchor deleted from a closed one) still fills as if closed, and its fill is +/// clicked on both sides of that closing line, reflected or not. +#[test] +fn an_open_filled_path_is_clicked_by_its_fill_either_way_round() { + // The sliver of a strawberry, open, its closing line down its left side. + let svg = r##""##; + for axis in [None, Some("vertical"), Some("horizontal")] { + let mut s = Session::new(); + s.add_document(vectorcraft_svg::import(svg).unwrap(), None); + s.execute("select.all", &json!({})).unwrap(); + let ids = s.doc().unwrap().selection.objects.clone(); + if let Some(axis) = axis { + s.execute("object.reflect", &json!({"axis": axis})).unwrap(); + } + let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + // The middle of the sliver's widest part, well inside it. + let x = if axis == Some("vertical") { b.x0 + b.width() * 0.4 } else { b.x0 + b.width() * 0.6 }; + assert_eq!(click(&mut s, x, b.center().y), ids, "reflected {axis:?}: a click in the fill (bounds {b:?})"); + } +} diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index 4de6cdbc3..cbaa5443a 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -563,3 +563,35 @@ fn automatic_alignment_updates_during_edits_and_can_be_overridden() { legacy["para"]["justify"] = json!("Left"); assert_eq!(serde_json::from_value::(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left); } + +/// #1063: a fill (or stroke) chosen while the Type tool has characters selected colours just +/// those characters, as one undo step; the rest of the type keeps its colour. +#[test] +fn a_fill_chosen_with_characters_selected_colours_only_them() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 50.0, 300.0); + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("Sample Text", v).unwrap(); + let before = obj(&s, id).runs[0].style.fill.clone(); + s.set_tool_option("select", &json!({"start": 0, "end": 6})); + s.execute("paint.setFill", &json!({"color": "#00a000"})).unwrap(); + let t = obj(&s, id); + assert_eq!(t.runs.len(), 2); + assert_eq!(t.runs[0].text, "Sample"); + assert_eq!(t.runs[0].style.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 160, 0))); + assert_eq!(t.runs[1].text, " Text"); + assert_eq!(t.runs[1].style.fill, before); + // A stroke goes on the selected characters only, with a weight to show. + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + let t = obj(&s, id); + assert!(t.runs[0].style.stroke_width > 0.0 && !t.runs[0].style.stroke.is_none()); + assert!(t.runs[1].style.stroke.is_none()); + // One undo step each. + let labels = undo_labels(&s); + assert_eq!(&labels[labels.len() - 2..], ["Fill Color", "Stroke Color"]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(obj(&s, id).runs.iter().all(|r| r.style.fill == before)); +} diff --git a/crates/engine/src/tests_typearea.rs b/crates/engine/src/tests_typearea.rs index 946fcd887..9c59eac5f 100644 --- a/crates/engine/src/tests_typearea.rs +++ b/crates/engine/src/tests_typearea.rs @@ -236,6 +236,9 @@ fn reshape_area_takes_untrusted_params() { json!({"id": id.0, "dx": 1, "dy": 1}), json!({"id": id.0, "anchors": [], "dx": 1, "dy": 1}), json!({"id": id.0, "anchors": [["a", 1], [0], "x"], "dx": 1, "dy": 1}), + // A malformed reference among good ones fails rather than moving only the good ones. + json!({"id": id.0, "anchors": [[0, 2], [0, "3"]], "dx": 1, "dy": 1}), + json!({"id": id.0, "anchors": [[0, 2], [0, 3, 1]], "dx": 1, "dy": 1}), json!({"id": id.0, "anchors": [[0, 9]], "dx": 1, "dy": 1}), json!({"id": id.0, "anchors": [[0, 1], [7, 0]], "dx": 1, "dy": 1}), json!({"id": id.0, "anchors": [[0, 2]], "dy": 1}), diff --git a/crates/engine/src/tests_typescale.rs b/crates/engine/src/tests_typescale.rs new file mode 100644 index 000000000..cfb4e1517 --- /dev/null +++ b/crates/engine/src/tests_typescale.rs @@ -0,0 +1,229 @@ +//! #1034: Character sizes and horizontal scales account for the object's affine transform. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind, TextObject}; +use vectorcraft_geom::Point; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn create(s: &mut Session, size: f64) -> NodeId { + NodeId(s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Axis label", "size": size})).unwrap()["id"].as_u64().unwrap()) +} + +fn text(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +fn em(t: &TextObject, run: usize) -> (f64, f64) { + let st = &t.runs[run].style; + let origin = t.xf * Point::ZERO; + ((t.xf * Point::new(st.size * st.h_scale / 100.0, 0.0) - origin).hypot(), (t.xf * Point::new(0.0, st.size * st.v_scale / 100.0) - origin).hypot()) +} + +fn near(actual: f64, expected: f64) { + assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}"); +} + +#[test] +fn scaled_font_size_setter_sets_document_points_and_undoes() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + s.execute("object.scale", &json!({"sx": 150})).unwrap(); + let before = text(&s, id); + near(em(&before, 0).1, 36.0); + s.execute("text.setStyle", &json!({"size": 10})).unwrap(); + let after = text(&s, id); + near(em(&after, 0).1, 10.0); + assert_eq!(after.xf, before.xf, "font editing preserves placement and rotation"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(text(&s, id), after); +} + +#[test] +fn group_font_size_setter_handles_different_accumulated_scales() { + let mut s = session(); + let a = create(&mut s, 12.0); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + let b = create(&mut s, 18.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + near(em(&text(&s, a), 0).1, 48.0); + near(em(&text(&s, b), 0).1, 36.0); + s.execute("text.setStyle", &json!({"size": 10})).unwrap(); + near(em(&text(&s, a), 0).1, 10.0); + near(em(&text(&s, b), 0).1, 10.0); +} + +#[test] +fn scaled_range_size_and_keyboard_steps_use_document_points() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0]})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "size": 10})).unwrap(); + near(em(&text(&s, id), 0).1, 10.0); + near(em(&text(&s, id), 1).1, 24.0); + s.prefs.type_size_increment = 2.0; + s.execute("type.step", &json!({"attribute": "size", "id": id.0, "start": 0, "end": 4})).unwrap(); + near(em(&text(&s, id), 0).1, 12.0); + near(em(&text(&s, id), 1).1, 24.0); +} + +#[test] +fn horizontal_scale_reset_removes_object_stretch_including_groups() { + for grouped in [false, true] { + let mut s = session(); + let id = create(&mut s, 12.0); + if grouped { + s.execute("object.group", &json!({})).unwrap(); + } + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let before = text(&s, id); + s.execute("text.setFormat", &json!({"hScale": 100, "vScale": 100})).unwrap(); + let after = text(&s, id); + near(em(&after, 0).0, 12.0); + near(em(&after, 0).1, 12.0); + assert_eq!(after.xf, before.xf); + } +} + +#[test] +fn rotated_reflected_nonuniform_range_edits_preserve_the_affine() { + let mut s = session(); + let id = create(&mut s, 12.0); + // A quarter turn, reflected in the baseline axis: horizontal ×2, vertical ×3. + s.execute("object.transform", &json!({"matrix": [0, -2, -3, 0, 0, 0]})).unwrap(); + let before = text(&s, id); + s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100, "vScale": 100})).unwrap(); + let after = text(&s, id); + near(em(&after, 0).0, 10.0); + near(em(&after, 0).1, 10.0); + assert_eq!(after.xf, before.xf); + // The native document representation retains both the compensation and the affine. + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let reopened = vectorcraft_format::load(&bytes).unwrap(); + let NodeKind::Text(t) = &reopened.node(id).unwrap().kind else { panic!("not text") }; + near(em(t, 0).0, 10.0); + near(em(t, 0).1, 10.0); +} + +#[test] +fn effective_range_query_leaves_raw_runs_available_for_clipboard() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap(); + let query = |s: &mut Session, effective| -> Value { + s.execute("text.getRange", &json!({"id": id.0, "effective": effective})).unwrap()["runs"][0]["style"].clone() + }; + let effective = query(&mut s, true); + assert_eq!(effective["size"], 24.0); + assert_eq!(effective["h_scale"], 150.0); + let raw = query(&mut s, false); + assert_eq!(raw["size"], 12.0); + assert_eq!(raw["h_scale"], 100.0); +} + +#[test] +fn scaled_point_units_and_small_sizes_are_converted_before_local_storage() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + s.execute("text.setStyle", &json!({"size": 0.1, "leading": 14})).unwrap(); + let t = text(&s, id); + near(em(&t, 0).1, 0.1); + near(t.runs[0].style.leading.unwrap() * 2.0, 14.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 12, "baselineShift": 4, "leading": 16})).unwrap(); + let t = text(&s, id); + near(t.runs[0].style.baseline_shift * 2.0, 4.0); + near(t.runs[0].style.leading.unwrap() * 2.0, 16.0); +} + +#[test] +fn singular_text_size_edit_returns_an_error_and_rolls_back_the_group() { + let mut s = session(); + let a = create(&mut s, 12.0); + let b = create(&mut s, 18.0); + s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + assert!(s.execute("text.setStyle", &json!({"size": 10})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.execute("text.setRangeStyle", &json!({"id": b.0, "size": 10})).is_err()); + assert!(s.execute("text.setFormat", &json!({"id": b.0, "hScale": 100})).is_err()); + // Non-dimensional edits remain possible, including on imported collapsed text. + s.execute("text.setStyle", &json!({"fill": "#123456"})).unwrap(); +} + +#[test] +fn sheared_text_keeps_its_affine_and_accepts_exact_em_dimensions() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.transform", &json!({"matrix": [2, 0, 1, 2, 0, 0]})).unwrap(); + let before = text(&s, id); + s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100})).unwrap(); + let after = text(&s, id); + near(em(&after, 0).0, 10.0); + near(em(&after, 0).1, 10.0); + assert_eq!(after.xf, before.xf); +} + +#[test] +fn area_frame_resize_reflows_at_same_size_and_object_scaling_reports_scaled_size() { + let mut s = session(); + let id = NodeId( + s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Area label", "size": 12, "area": {"width": 120, "height": 80}})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0], "typeAreas": true})).unwrap(); + near(em(&text(&s, id), 0).1, 12.0); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + near(em(&text(&s, id), 0).1, 24.0); + s.execute("text.setStyle", &json!({"size": 10})).unwrap(); + near(em(&text(&s, id), 0).1, 10.0); +} + +#[test] +fn point_based_keyboard_increments_and_format_fields_follow_document_units() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + s.execute("text.setStyle", &json!({"leading": 14})).unwrap(); + s.execute("text.setFormat", &json!({"baselineShift": 4})).unwrap(); + s.prefs.type_size_increment = 2.0; + s.prefs.baseline_shift_increment = 3.0; + s.execute("type.step", &json!({"attribute": "leading"})).unwrap(); + s.execute("type.step", &json!({"attribute": "baselineShift"})).unwrap(); + let t = text(&s, id); + near(t.runs[0].style.leading.unwrap() * 2.0, 16.0); + near(t.runs[0].style.baseline_shift * 2.0, 7.0); +} + +#[test] +fn tiny_finite_transform_rejects_unsafe_compensation_without_history_changes() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.transform", &json!({"matrix": [1e-306, 0, 0, 1e-306, 0, 0]})).unwrap(); + let before = text(&s, id); + let history = s.doc().unwrap().history.undo.len(); + let error = s.execute("text.setStyle", &json!({"size": 160})).unwrap_err(); + assert!(matches!(error, EngineError::BadParams { .. }), "reject before attempting unsafe layout: {error}"); + assert_eq!(text(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), history); +} diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests_xform.rs index 0faf7cea6..f5798ffb6 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests_xform.rs @@ -572,3 +572,27 @@ fn copy_from_requires_source() { assert!(s.execute("appearance.copyFrom", &json!({})).is_err()); assert!(s.execute("appearance.copyFrom", &json!({"source": 9999})).is_err()); } + +#[test] +fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let original = bounds(&s, a); + let history = undo_len(&s); + let corners = json!([[10, 0], [90, 0], [100, 100], [0, 100]]); + for p in [ + json!({"corners": [[10, 0, 50], [90, 0], [100, 100], [0, 100]]}), + json!({"corners": [[10, 0], [90, 0], ["bad", 100], [0, 100]]}), + json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100], ["bad"]]}), + json!({"corners": corners, "from": [0, 0, 100]}), + json!({"corners": corners, "from": "not bounds"}), + json!({"corners": corners, "ids": [a.0, "bad"]}), + json!({"corners": corners, "ids": [a.0, u64::MAX]}), + ] { + assert!(s.execute("object.distort", &p).is_err(), "invalid input: {p}"); + assert!(close(bounds(&s, a), original)); + assert_eq!(undo_len(&s), history); + } + s.execute("object.distort", &json!({"corners": corners, "from": [0, 0, 100, 100], "ids": [a.0]})).unwrap(); + assert_eq!(undo_len(&s), history + 1); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 1ad18115d..c383051f7 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -81,23 +81,6 @@ fn every_command_without_a_document() { assert!(failures.is_empty(), "{}", failures.join("\n")); } -/// Known bugs (see the `#[ignore]` repro tests below): (command, param key) pairs skipped by the -/// junk sweep so the rest of the sweep stays meaningful. -const KNOWN_BUGS: &[(&str, &str)] = &[ - ("object.scale", "sx"), - ("object.transformEach", "scaleH"), - ("object.transformEach", "scaleV"), - ("object.distributeSpacing", "spacing"), - ("artboard.setProps", "x"), - ("artboard.setProps", "y"), - ("artboard.setProps", "width"), - ("artboard.setProps", "height"), -]; - -fn known_bug(id: &str, p: &Value) -> bool { - KNOWN_BUGS.iter().any(|(c, k)| *c == id && p.get(*k).is_some()) -} - /// Regression: huge column counts used to panic with `capacity overflow`; in every layout and /// order (#681), with huge and negative spacing too. #[test] @@ -157,9 +140,6 @@ fn every_command_with_junk_params() { cases.truncate(12); } for p in &cases { - if known_bug(c.id, p) { - continue; - } calls += 1; if let Some(f) = probe(fx, c.id, p) { failures.push(f); @@ -292,6 +272,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}})), @@ -474,9 +474,6 @@ fn fuzzed_calls_keep_documents_serializable() { let k = if k == "path" { continue } else { k }; let mut s = Fixture::Multi.session(); let p = json!({ k.clone(): v }); - if known_bug(c.id, &p) { - continue; - } let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false); if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) { failures.push(format!("{} {p}: {e}", c.id)); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index bbab8fa3f..ba7f6fe15 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1280,11 +1280,17 @@ proptest! { /// Hostile PostScript programs: operators in any order with any operands, opened and placed; /// as files in the legacy Illustrator format too (the creator line is what turns on its - /// documented `u` … `U` groups), the groups unbalanced, deep and among clips. + /// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which + /// the editing data reader reads from the program too. #[test] - fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::()) { + fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::(), layers in any::()) { let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" }; - let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{}\nshowpage\n", tokens.join(" ")); + let (open, close) = if layers { + ("/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def\n%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(L) Ln\n", "\nLB\n%AI5_EndLayer--") + } else { + ("", "") + }; + let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{open}{}{close}\nshowpage\n", tokens.join(" ")); survive("hostile PostScript", || vectorcraft_engine::cmd::fileio::load("x.eps", ps.as_bytes()).ok().map(|l| l.doc))?; let r = catch_quiet(|| { let mut s = rich_session(); @@ -1702,6 +1708,9 @@ fn ascii85(data: &[u8]) -> String { out } +/// A pattern's definition in editing data: its name and tile, its art on `%_` lines, its end. +const PATTERN: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n%_0 A\n%_0 Ae\n%_u\n%_0 0 m\n%_0 6 L\n%_6 6 L\n%_6 0 L\n%_n\n%_0 0 0 1 K\n%_1 J 1 w\n%_3 3 m\n%_3 3 L\n%_S\n%_U\n%_9 () XW\nE\n%AI3_EndPattern\n"; + /// The editing data of a file with every sort of object the layers reader knows, and some it doesn't. fn editing_text() -> String { let square = |x: u32| format!("0 0 1 0 k\n{x} 10 m\n{} 10 L\n{} 14 L\n{x} 14 L\nf", x + 4, x + 4); @@ -1724,13 +1733,16 @@ fn editing_text() -> String { "0 Xw".into(), "1 0 0 0 1 0 Bg".into(), "0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(), + "0 0 0 1 (Black global) 0.4 0 Xk".into(), + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(), + square(60), ] .join("\n"); let layer = |name: &str, visible: u8, body: &str| { format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n") }; format!( - "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{}{}%%Trailer\n", + "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{PATTERN}{}{}%%Trailer\n", layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))), layer("Two", 0, &square(80)) ) @@ -1872,6 +1884,35 @@ proptest! { } } +/// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named, +/// not included, so its program can't be run and its layers are read from it. +const NAMED_PROLOG_AI: &str = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 200 100\n%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n\ + %%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\nAdobe_packedarray /initialize get exec\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(A::B) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M [2 1]0 d\n0 D\n10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\n\ + u\n0.5 g\n20 20 m\n40 20 L\n40 40 L\nf\nU\n%AI5_BeginLayer\n0 1 1 1 0 0 3 0 0 0 Lb\n(Sub) Ln\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\ + %%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"; + +/// The fixture, as it is, opens with its layer and sublayer. +#[test] +fn the_named_prolog_fixture_opens_with_its_layers() { + let l = vectorcraft_engine::cmd::fileio::load("x.ai", NAMED_PROLOG_AI.as_bytes()).unwrap(); + let names: Vec<_> = l.doc.layers.iter().map(|l| l.name.clone()).collect(); + assert_eq!(names, [Some("A::B".to_string())], "{:?}", l.warnings); + assert_eq!(l.doc.layers[0].children().unwrap().len(), 3); +} + +proptest! { + #![proptest_config(config())] + + /// A `.ai` in PostScript form whose prolog is named, not included, damaged: read from its + /// layers, or refused. + #[test] + fn ps_form_ai_never_panics(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '%', 'u', 'U', 'L', 'b', 'B', 'i', 'A', 'K'])), 0..12)) { + let text = mutate_text(NAMED_PROLOG_AI, cut, &edits); + survive("mutated PostScript-form .ai", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?; + } +} + /// The operators, operands and section comments of hostile editing data. fn arb_ai_token() -> impl Strategy { prop_oneof![ @@ -1980,6 +2021,16 @@ fn arb_ai_token() -> impl Strategy { "/Binary : /ASCII85Decode ,", "~>", "p", + "P", + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", + "(Dots) 0 0 1 1 0 0 0 0 0 [0 0 0 0 1e308 0] P", + "Xk", + "XK", + "0 0 0 1 (Global) 0.4 0 Xk", + "1 0.5 0 (Global) 0 1 XK", + "E", + "\n%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n", + "\n%AI3_EndPattern\n", "To", "frobnicate", "\n%AI5_BeginLayer\n", diff --git a/crates/eps/src/import/ai/lex.rs b/crates/eps/src/import/ai/lex.rs index 25f757b7f..b30481b52 100644 --- a/crates/eps/src/import/ai/lex.rs +++ b/crates/eps/src/import/ai/lex.rs @@ -81,6 +81,16 @@ impl<'a> Lexer<'a> { rest.get(..n).unwrap_or_default() } + /// Where the next token is looked for. + pub fn offset(&self) -> usize { + self.pos + } + + /// The data from `from` to `to` (empty where that isn't in it). + pub fn slice(&self, from: usize, to: usize) -> &'a [u8] { + self.src.get(from..to).unwrap_or_default() + } + /// Skip past the next `needle` (the end of data that isn't tokens) → what was skipped. pub fn skip_past(&mut self, needle: &[u8]) -> &'a [u8] { let rest = self.rest(); diff --git a/crates/eps/src/import/ai/mod.rs b/crates/eps/src/import/ai/mod.rs index 489fb1ac0..c753fc724 100644 --- a/crates/eps/src/import/ai/mod.rs +++ b/crates/eps/src/import/ai/mod.rs @@ -52,6 +52,11 @@ pub fn eps_data(ps: &[u8]) -> Option, String>> { Some(eps_section(section)) } +/// Does the EPS `ps` carry editing data ([`eps_data`] isn't `None`)? +pub fn has_eps_data(ps: &[u8]) -> bool { + find(ps, EPS_BEGIN).is_some() +} + /// The editing data between the EPS markers: header comments, then the data (encoded or not). fn eps_section(section: &[u8]) -> Result, String> { let mut header = Vec::new(); diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index 2358b65f8..783e7e448 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -2,8 +2,10 @@ //! //! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint //! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK -//! values its equivalent), `Xx`/`XX` a named colour of either model -//! (`c m y k [r g b] (name) tint type`). Lower case paints fills, upper case strokes. +//! values its equivalent), `Xx`/`XX` a named colour of any model +//! (`c m y k [r g b | L a b] (name) tint type`, type 0 CMYK, 1 RGB, 2 Lab), `Xk`/`XK` a global +//! process colour (its operands as `Xx`'s: a global swatch that isn't an ink of its own). Lower +//! case paints fills, upper case strokes. //! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6] //! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`. //! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d @@ -28,6 +30,8 @@ pub(super) struct Named { /// The full colour. pub color: Color, pub tint: f32, + /// An ink of its own (a spot colour), not a global process colour. + pub spot: bool, } /// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named @@ -48,12 +52,13 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { } "x" => named(vals, Some(false)), "xx" => named(vals, None), + "xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))), _ => None, } } -/// `c m y k (name) tint x` or `c m y k [r g b] (name) tint type Xx` (type 1: RGB); `rgb` forces -/// the model. +/// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab); +/// `rgb` forces RGB or CMYK. fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?; let name = vals.get(at)?.text()?; @@ -62,9 +67,14 @@ fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { let unit = |v: f32| v.clamp(0.0, 1.0); let tint = 1.0 - unit(*after.first()?); let n = before.len(); - let color = if rgb.unwrap_or(after.get(1).is_some_and(|t| *t == 1.0)) { + let kind = after.get(1).copied(); + let color = if rgb.unwrap_or(kind == Some(1.0)) { let [r, g, b] = before.get(n.checked_sub(3)?..)? else { return None }; Color::rgb(unit(*r), unit(*g), unit(*b)) + } else if rgb.is_none() && kind == Some(2.0) { + // Type 2: a colour defined in Lab, its L a b values after the CMYK equivalent (#1032). + let [l, a, b] = before.get(n.checked_sub(3)?..)? else { return None }; + Color::lab(l.clamp(0.0, 100.0), a.clamp(-128.0, 127.0), b.clamp(-128.0, 127.0)) } else { // The CMYK values come first (an RGB equivalent may follow them). let from = if n >= 7 { n - 7 } else { n.checked_sub(4)? }; @@ -72,7 +82,7 @@ fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k)) }; let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint }; - Some((paint, Some(Named { name, color, tint }))) + Some((paint, Some(Named { name, color, tint, spot: true }))) } /// The colour of a gradient stop's colour values and style. @@ -162,7 +172,7 @@ pub(super) struct Instance { hilight: Vec2, } -fn affine(n: &[f64]) -> Option { +pub(super) fn affine(n: &[f64]) -> Option { let [a, b, c, d, e, f] = n else { return None }; let m = Affine::new([*a, *b, *c, *d, *e, *f]); (m.as_coeffs().iter().all(|v| v.is_finite())).then_some(m) @@ -247,6 +257,30 @@ mod tests { assert!((named.tint - 0.6).abs() < 1e-6); assert!(matches!(p, Paint::Solid { swatch: Some(_), .. })); assert!(color_op("k", &nums(&[1.0])).is_none()); + // A global process colour: CMYK (type 0) or RGB (type 1), not an ink. + let mut v = nums(&[0.0, 0.0, 0.0, 1.0]); + v.extend([V::Str(b"K100".to_vec()), V::Num(0.4), V::Num(0.0)]); + let (p, named) = color_op("XK", &v).unwrap(); + let named = named.unwrap(); + assert_eq!((named.name.as_str(), named.color, named.spot), ("K100", Color::cmyk(0.0, 0.0, 0.0, 1.0), false)); + assert!((named.tint - 0.6).abs() < 1e-6); + assert!( + matches!(p, Paint::Solid { swatch: Some(s), tint, color: Color::Cmyk { k, .. } } if s == "K100" && (tint - 0.6).abs() < 1e-6 && (k - 0.6).abs() < 1e-6) + ); + let mut v = nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.5, 0.0]); + v.extend([V::Str(b"Orange".to_vec()), V::Num(0.0), V::Num(1.0)]); + let (p, _) = color_op("Xk", &v).unwrap(); + assert_eq!(p.color(), Some(Color::rgb(1.0, 0.5, 0.0))); + // A spot colour defined in Lab (type 2): its L a b values, not its CMYK equivalent (#1032). + let mut v = nums(&[0.52, 0.98, 0.28, 0.1, 32.9412, 45.0, -18.0]); + v.extend([V::Str(b"Plum Ink".to_vec()), V::Num(0.0), V::Num(2.0)]); + let (p, named) = color_op("Xx", &v).unwrap(); + let named = named.unwrap(); + assert_eq!((named.color, named.spot, named.tint), (Color::lab(32.9412, 45.0, -18.0), true, 1.0)); + assert_eq!(p.color(), Some(Color::lab(32.9412, 45.0, -18.0))); + v[8] = V::Num(0.25); + let (_, named) = color_op("XX", &v).unwrap(); + assert_eq!(named.map(|n| (n.color, n.tint)), Some((Color::lab(32.9412, 45.0, -18.0), 0.75))); } #[test] diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index b018f68af..6d4f2aa4c 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -2,23 +2,31 @@ //! //! - Art space has y up; the first artboard's top left corner is the document's origin. //! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b … -//! dimPercent … Lb`, `(name) Ln`, its art, `LB`. +//! dimPercent … Lb` (ten operands in the legacy format, without the one before the colour +//! index), `(name) Ln`, its art, `LB`. //! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted //! with `W` clips); a group's transparency and name follow its end. //! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case //! closes), `(op) *` for a guide. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`. //! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`. //! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id. -//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`. +//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`, else +//! the art size around the template box's centre, else (a file in the legacy format another +//! app wrote) the bounding box. //! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …` //! and the samples after `XI`. //! - Art with an appearance the format can't write as plain art (several fills or strokes, //! effects, brushes) comes first as that look, drawn (a group), then the object itself on `%_` //! lines and `1 (style) XW`: the drawn look is kept, named after the object. +//! - Patterns: `%AI3_BeginPattern: (name)`, `(name) llx lly urx ury` (the tile, in the pattern's +//! space, y up), the tile's art (on `%_` lines in newer files), `E`, `%AI3_EndPattern`; each is +//! read by a reader of its own and becomes a pattern swatch. A fill or stroke with one: +//! `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`), the matrix +//! taking the pattern's space to art space (the other operands are what it is made of). //! -//! Sections the reader doesn't need (patterns, brushes, symbols, styles, swatches, filters) are -//! skipped; type, symbols, pattern fills, placed files, legacy type and operators it doesn't know -//! are errors. +//! Sections the reader doesn't need (brushes, symbols, styles, swatches, filters) are skipped; +//! type, symbols, fills with a pattern it can't read, placed files, legacy type and operators it +//! doesn't know are errors. use std::collections::{BTreeSet, HashMap, HashSet}; use std::rc::Rc; @@ -30,7 +38,7 @@ use vectorcraft_color::swatch::Swatch; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{ Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin, - Node, NodeId, NodeKind, StrokeLayer, + Node, NodeId, NodeKind, PatternDef, StrokeLayer, }; use vectorcraft_geom::{FillRule, PathData}; @@ -51,6 +59,10 @@ const MAX_POINTS: usize = 4_000_000; const MAX_PIXELS: u64 = 1 << 25; /// Largest side of the document and its artboards (points). const MAX_SIDE: f64 = 1e6; +/// Art space (y up) → a pattern's space in the document's terms (y down). +const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); +/// Most patterns read (others can't fill art). +const MAX_PATTERNS: usize = 4096; /// What the editing data holds: the document of its art, and where its art space is. #[derive(Debug)] @@ -197,6 +209,16 @@ struct Frame { clips: Vec, } +/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands +/// before its first operator. +#[derive(Debug, Default)] +struct PatternHead { + taken: bool, + name: String, + /// The tile in the pattern's space (y up). + tile: Option, +} + /// An image's operands, from `XN` and before `XI`. #[derive(Debug, Default)] struct Raster { @@ -260,6 +282,10 @@ struct Reader<'a> { /// The artboard's size and centre in files without artboards or crop marks. art_size: Option<(f64, f64)>, template_center: Option<(f64, f64)>, + /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). + pattern: Option, + /// The tile's top left (y down) of each pattern read, by name. + pattern_tiles: HashMap, } /// Operators that change nothing the reader keeps. @@ -281,6 +307,8 @@ const IGNORED: &[&str] = &[ "Xm", "XP", "Np", + // Flatness (`i`): how finely curves print. + "i", "TE", "TZ", "Xs", @@ -377,6 +405,8 @@ impl<'a> Reader<'a> { slot_names: HashMap::new(), art_size: None, template_center: None, + pattern: None, + pattern_tiles: HashMap::new(), } } @@ -429,6 +459,12 @@ impl<'a> Reader<'a> { let line = String::from_utf8_lossy(c); let line = line.trim_end(); let m = marker(line); + // A pattern's definition (one inside another's isn't read). + if m == "BeginPattern" && self.pattern.is_none() { + let section = self.section("EndPattern")?; + self.define_pattern(section); + return Ok(()); + } if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) { return self.skip_to(end); } @@ -481,10 +517,18 @@ impl<'a> Reader<'a> { /// Skip to the comment with marker `end` (sections of the same kind nested in it count, and /// versioned content in an alternate). fn skip_to(&mut self, end: &str) -> Result<(), String> { + self.section(end).map(|_| ()) + } + + /// [`Self::skip_to`] → what was skipped, up to the comment that ends it. + fn section(&mut self, end: &str) -> Result<&'a [u8], String> { let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b); let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin }; + let start = self.lex.offset(); let mut depth = 0usize; - while let Some(t) = self.lex.next_token() { + loop { + let before = self.lex.offset(); + let Some(t) = self.lex.next_token() else { break }; if let Tok::Comment(c) = t.tok { let line = String::from_utf8_lossy(c); let m = marker(&line); @@ -492,7 +536,7 @@ impl<'a> Reader<'a> { depth += 1; } else if m == end { if depth == 0 { - return Ok(()); + return Ok(self.lex.slice(start, before)); } depth -= 1; } @@ -501,6 +545,58 @@ impl<'a> Reader<'a> { Err(format!("its section ending with `%{end}` doesn't end")) } + /// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its + /// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't + /// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art + /// filled with it can't be read. + fn define_pattern(&mut self, section: &[u8]) { + if self.pattern_tiles.len() >= MAX_PATTERNS { + return; + } + let mut sub = Reader::new(section); + sub.pattern = Some(PatternHead::default()); + // The gradients defined so far, lent (and those it defines kept). + sub.gradients = std::mem::take(&mut self.gradients); + sub.nodes = self.nodes; + let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); + self.nodes = sub.nodes; + self.gradients = std::mem::take(&mut sub.gradients); + let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1; + let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return }; + let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else { + return; + }; + let art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); + let mut def = PatternDef::new(&head.name, art); + def.tile = FLIP.transform_rect_bbox(tile); + if self.pattern_tiles.insert(def.name.clone(), def.tile.origin()).is_some() { + self.doc.patterns.retain(|p| p.name != def.name); + } + self.doc.patterns.push(def); + for (key, blob) in sub.doc.images { + self.doc.images.entry(key).or_insert(blob); + } + self.spots.extend(sub.spots); + self.cmyk_colors += sub.cmyk_colors; + self.rgb_colors += sub.rgb_colors; + for w in &sub.warnings { + self.warn(w); + } + } + + /// In a pattern's definition, the operands of its first operator start with its head, + /// `(name) llx lly urx ury`: taken off them. + fn take_head(&mut self, mut vals: Vec) -> Vec { + let Some(head) = self.pattern.as_mut().filter(|h| !h.taken) else { return vals }; + head.taken = true; + let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals }; + let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default()); + let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals }; + head.name = name; + head.tile = Some(Rect::new(*x0, *y0, *x1, *y1)); + vals.split_off(at + 5) + } + // ---- frames ---------------------------------------------------------------------------- fn open(&mut self, kind: Kind, hidden: bool) -> Result<(), String> { @@ -682,6 +778,7 @@ impl<'a> Reader<'a> { self.after_object = false; } let vals = self.operands(); + let vals = self.take_head(vals); if let Some(d) = self.gradient_def.as_mut() { match w { "Bs" => d.stop(&vals, &mut self.spots), @@ -728,8 +825,10 @@ impl<'a> Reader<'a> { self.paint(&op, hidden, true)?; } // Colours. - "g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" => self.color(w, &vals), - "p" | "P" => self.unreadable("pattern fills"), + "g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" => self.color(w, &vals), + "p" | "P" => self.pattern_paint(w, &vals), + // A pattern's definition ends. + "E" if self.pattern.is_some() => self.done = true, "O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0), "R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0), "XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero }, @@ -856,6 +955,9 @@ impl<'a> Reader<'a> { /// A text object (`/AI11Text`): an empty group standing for it, named after its story. fn text_slot(&mut self, o: &Obj) -> Result<(), String> { + if self.pattern.is_some() { + self.unreadable("type in a pattern"); + } let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32); let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0)); let gs = self.gs.clone(); @@ -893,7 +995,7 @@ impl<'a> Reader<'a> { self.warn(NON_NATIVE_ART_UNREAD); return Ok(()); } - let target = self.ensure_space().transform_rect_bbox(art_box); + let target = self.space().transform_rect_bbox(art_box); let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() }; let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) { Ok(r) => r, @@ -1001,7 +1103,8 @@ impl<'a> Reader<'a> { } } - /// `… Lb`: a layer's options. + /// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place + /// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`). fn layer_attrs(&mut self, vals: &[V]) { let n = Self::nums(vals); let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return }; @@ -1012,15 +1115,44 @@ impl<'a> Reader<'a> { a.printable = flag(3, true); let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0); a.dim = flag(4, false).then_some(dim_pct.round() as u8); - let index = n.get(7).copied().unwrap_or(0.0); - let rgb = |i: usize| n.get(i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); + let at = if n.len() <= 10 { 6 } else { 7 }; + let index = n.get(at).copied().unwrap_or(0.0); + let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 { LayerColor::Preset(index as u8) } else { - LayerColor::Custom([rgb(8), rgb(9), rgb(10)]) + LayerColor::Custom([rgb(1), rgb(2), rgb(3)]) }); } + /// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's): + /// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't + /// read can't be drawn. + fn pattern_paint(&mut self, w: &str, vals: &[V]) { + // In the setup it paints nothing (and the art's space isn't fixed yet). + if self.frames.is_empty() { + return; + } + let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); + let Some(origin) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).map(|p| p.to_vec2()) else { + self.unreadable("pattern fills"); + return; + }; + let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); + // The tile's art (y down from the tile's top left) → the pattern's space → art space. + let xf = self.space() * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate(origin); + if xf.determinant().abs() < 1e-12 || !xf.as_coeffs().iter().all(|v| v.is_finite()) { + self.unreadable("pattern fills"); + return; + } + let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf }; + if w == "p" { + self.gs.fill = p; + } else { + self.gs.stroke = p; + } + } + fn color(&mut self, w: &str, vals: &[V]) { let Some((p, named)) = paint::color_op(w, vals) else { return }; match p.color() { @@ -1031,7 +1163,7 @@ impl<'a> Reader<'a> { if let Some(n) = named { self.spots.push(n); } - if w.chars().all(|c| c.is_ascii_lowercase()) || w == "Xa" || w == "Xx" { + if w.ends_with(|c: char| c.is_ascii_lowercase()) { self.gs.fill = p; } else { self.gs.stroke = p; @@ -1165,7 +1297,7 @@ impl<'a> Reader<'a> { if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) { close(&mut bp); } - let to_doc = self.ensure_space(); + let to_doc = self.space(); let path = PathData::from_bezpath(&(to_doc * bp)); let gs = self.gs.clone(); let (fill, stroke) = match op.to_ascii_lowercase().as_str() { @@ -1200,7 +1332,7 @@ impl<'a> Reader<'a> { } fn set_instance(&mut self, i: &Instance) { - let to_doc = self.ensure_space(); + let to_doc = self.space(); match i.paint(&self.gradients, to_doc) { Some(p) if i.stroke => self.pending_stroke = Some(p), Some(p) => self.pending_fill = Some(p), @@ -1286,9 +1418,8 @@ impl<'a> Reader<'a> { } // The matrix maps pixels (y down) into art space with its y flipped. let [a, b, c, d, tx, ty] = m.as_slice() else { return Ok(()) }; - let to_doc = self.ensure_space(); - let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); - let xf = to_doc * flip * Affine::new([*a, *b, *c, *d, *tx, -*ty]); + let to_doc = self.space(); + let xf = to_doc * FLIP * Affine::new([*a, *b, *c, *d, *tx, -*ty]); if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { return Ok(()); } @@ -1314,18 +1445,36 @@ impl<'a> Reader<'a> { (sane(r) && w > 0.0 && h > 0.0).then_some(r) } + /// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`]. + fn first_board(&self) -> Option { + self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()) + } + + /// The page of a file that says nothing of its artboard (the legacy format as other apps write + /// it): its art's bounding box. + fn bbox_board(&self) -> Option { + let [x0, y0, x1, y1] = self.hires.or(self.bbox)?; + Some(Rect::new(x0, y0, x1, y1)).filter(|r| r.width() > 0.0 && r.height() > 0.0) + } + /// Art space → the document (fixed when the art starts, from the artboards read by then). fn ensure_space(&mut self) -> Affine { if let Some(m) = self.to_doc { return m; } - let first = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()); + let first = self.first_board().or_else(|| self.bbox_board()); let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1)); let m = Affine::new([1.0, 0.0, 0.0, -1.0, -top_left.x, top_left.y]); self.to_doc = Some(m); m } + /// Art space → where the art being read goes: the document, or while a pattern's definition is + /// read, the pattern's space (y down). + fn space(&mut self) -> Affine { + if self.pattern.is_some() { FLIP } else { self.ensure_space() } + } + fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 { self.warnings.push(w.to_string()); @@ -1345,11 +1494,11 @@ impl<'a> Reader<'a> { return Err("it has no layers".into()); } let to_doc = self.ensure_space(); - let artboard = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()); + let artboard = self.first_board(); drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); let boards: Vec<(String, Rect)> = if self.artboards.is_empty() { - self.cropmarks.or_else(|| self.sized_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() + artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() } else { self.artboards.clone() }; @@ -1374,7 +1523,12 @@ impl<'a> Reader<'a> { } for n in &self.spots { if doc.swatch(&n.name).is_none() { - doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: true }); + doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot }); + } + } + for name in doc.patterns.iter().map(|p| p.name.clone()).collect::>() { + if doc.swatch(&name).is_none() { + doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false }); } } let mut warnings = self.warnings; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 25ded7b3b..70c07b4cc 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -132,6 +132,92 @@ fn paths_colours_and_strokes() { assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some()); } +/// `Xk` and `XK` are global process colours: a name, a tint and whether the colour is RGB, as +/// `Xx` has them (#1025). They come in as global swatches that aren't inks, linked at their tint. +#[test] +fn global_process_colours_are_global_swatches_at_their_tint() { + let art_ = format!("0 0 0 1 (Black global) 0.4 0 Xk\n0 0 0 0 1 0.5 0 (Brand orange) 0 1 XK\n{}b\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let doc = read_ok(&layer(&art_)).doc; + let a = art(&doc); + let Paint::Solid { swatch, tint, color } = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) }; + assert_eq!(swatch.as_deref(), Some("Black global")); + assert!((tint - 0.6).abs() < 1e-6 && matches!(color, Color::Cmyk { k, .. } if (k - 0.6).abs() < 1e-6), "{tint} {color:?}"); + let stroke = a[0].appearance.stroke().unwrap().paint.clone(); + assert!( + matches!(&stroke, Paint::Solid { swatch: Some(s), tint, color } if s == "Brand orange" && *tint == 1.0 && *color == Color::rgb(1.0, 0.5, 0.0)), + "{stroke:?}" + ); + for name in ["Black global", "Brand orange"] { + let sw = doc.swatch(name).unwrap(); + assert!(sw.global && !sw.spot, "{sw:?}"); + } + assert_eq!(doc.swatch("Black global").unwrap().paint.color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the swatch is the full colour"); +} + +/// A pattern's definition (`%AI3_BeginPattern`): its name and tile (8 points square here, y up), +/// then its art on `%_` lines (a group of the tile's unpainted box and a dot, a round-capped +/// stroke of no length), `E`. +const DOTS: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 8 8\n%_0 A\n%_0 Xw\n%_0 Ae\n%_u\n%_0 O\n%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L\n%_n\n\ + %_0 R\n%_0 0 0 1 K\n%_1 J 0 j 2 w 4 M []0 d\n%_4 4 m\n%_4 4 L\n%_S\n%_U\n%_/ArtDictionary :\n%_;\n%_\n%_9 () XW\nE\n%AI3_EndPattern\n"; + +/// Patterns are read into pattern swatches, and `p` fills with one (#1025): the art keeps its +/// layers, the fill places the tiles by its matrix. +#[test] +fn patterns_are_pattern_swatches_and_fill_with_them() { + let sub = |name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{art}LB\n%AI5_EndLayer--\n"); + let body = format!( + "{DOTS}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Ground) Ln\n{}{}LB\n%AI5_EndLayer--\n", + sub("Lawn", &format!("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0))), + sub("Paving", &format!("(Dots) 10 20 2 2 0 0 0 0 0 [2 0 0 2 10 20] P\n{}S\n", rect_path(90.0, 20.0, 150.0, 80.0))), + ); + let s = read_ok(&body); + let doc = &s.doc; + let ground = &doc.layers[0]; + let subs: Vec<_> = ground.children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(subs, ["Lawn", "Paving"]); + let def = doc.pattern("Dots").unwrap(); + assert_eq!(def.tile, Rect::new(0.0, -8.0, 8.0, 0.0), "the tile, y down"); + assert_eq!(def.art.len(), 1, "the tile's group"); + let tile_box = &def.art[0].children().unwrap()[0]; + assert_eq!(tile_box.geometric_bounds(), Some(def.tile), "the tile's box is where the tile is"); + assert_eq!(def.art[0].children().unwrap().len(), 2); + assert!(matches!(&doc.swatch("Dots").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Dots")); + let lawn = &ground.children().unwrap()[0].children().unwrap()[0]; + let Paint::Pattern { pattern, xf } = fill(lawn) else { panic!("{:?}", fill(lawn)) }; + assert_eq!(pattern, "Dots"); + // The tile's top left (0, 8) in art space is (0, 92) on the page (crop marks 100 high); a dot + // centre (4, 4) is at (4, 96). + assert_eq!(xf * Point::ZERO, Point::new(0.0, 92.0)); + assert_eq!(xf * Point::new(4.0, 4.0), Point::new(4.0, 96.0)); + // A stroke with it, scaled twice and moved by (10, 20): the tile's top left is at (10, 36) in + // art space. + let paving = &ground.children().unwrap()[1].children().unwrap()[0]; + let Some(Paint::Pattern { xf, .. }) = paving.appearance.stroke().map(|st| st.paint.clone()) else { panic!() }; + assert_eq!(xf * Point::ZERO, Point::new(10.0, 64.0)); + assert!(s.warnings.is_empty(), "{:?}", s.warnings); +} + +/// A pattern whose definition it can't read whole isn't kept: a fill with it is something the file +/// has that isn't read, where it shows. Its definition costs nothing else. +#[test] +fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() { + let unread = "%AI3_BeginPattern: (Old)\n(Old) 0 0 8 8 [\n(0 O 0 R 0 g) @\n(\n0 0 m 8 8 L S\n) &\n] E\n%AI3_EndPattern\n\ + %AI3_BeginPattern: (Odd)\n(Odd) 0 0 8 8\n%_U\nE\n%AI3_EndPattern\n%AI3_BeginPattern: (Flat)\n(Flat) 0 0 8 0\n%_0 0 m\n%_8 0 L\n%_S\nE\n%AI3_EndPattern\n"; + let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. }))); + for name in ["Old", "Odd", "Flat"] { + let e = read( + stream(&format!("{unread}{}", layer(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0))))) + .as_bytes(), + ) + .unwrap_err(); + assert!(e.contains("pattern fills"), "{name}: {e}"); + } + // Type in a pattern isn't read either. + let typed = "%AI3_BeginPattern: (Words)\n(Words) 0 0 8 8\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\nE\n%AI3_EndPattern\n"; + assert!(read_ok(&format!("{typed}{}", layer(""))).doc.patterns.is_empty()); +} + #[test] fn compound_paths_and_clipping() { let art_ = format!( diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 255da6963..1bbf83379 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -339,6 +339,12 @@ fn turn(a: f64, b: f64) -> f64 { if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d } } +/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left +/// of each after whole turns. +fn ccw_turn(a: f64, b: f64) -> f64 { + (b % 360.0 - a % 360.0).rem_euclid(360.0) +} + impl Interp<'_> { /// User space → document space. pub fn xf(&self) -> Affine { @@ -436,18 +442,19 @@ impl Interp<'_> { /// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise). fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res { - let (a1, mut a2) = (a1.to_radians(), a2.to_radians()); - let tau = std::f64::consts::TAU; - if ccw { - while a2 < a1 { - a2 += tau; - } + // `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it + // until it is at most `a1` (PLRM 3rd ed., `arc` and `arcn` in chapter 8). Those sweeps, + // and the first endpoint, are worked out from what is left of each angle after whole turns. + let sweep = if ccw && a2 < a1 { + ccw_turn(a1, a2) + } else if !ccw && a2 > a1 { + -ccw_turn(a2, a1) } else { - while a2 > a1 { - a2 -= tau; - } - } - let sweep = (a2 - a1).clamp(-tau * 4.0, tau * 4.0); + a2 - a1 + }; + let tau = std::f64::consts::TAU; + let sweep = sweep.to_radians().clamp(-tau * 4.0, tau * 4.0); + let a1 = (a1 % 360.0).to_radians(); let start = c + Vec2::new(r * a1.cos(), r * a1.sin()); self.append_user(std::iter::once(PathEl::MoveTo(start)))?; let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0); diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import/mod.rs index 8dac8a78f..02906f668 100644 --- a/crates/eps/src/import/mod.rs +++ b/crates/eps/src/import/mod.rs @@ -16,14 +16,18 @@ //! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in //! as groups. The format's published specification documents its header comments (`%AI…`, //! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0 -//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. +//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. A file in that +//! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads +//! an Illustrator EPS's editing copy (#1027). //! //! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page //! of a PostScript file without one. A file whose program can't be read (an operator the -//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in as its -//! preview (a TIFF image, a Windows metafile or an EPSI bitmap), with a warning that names the -//! error, the operator and the procedures it ran in; without a preview, what was drawn before the -//! error is kept (with that warning), else the file is refused. +//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its +//! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not +//! included, as other apps write it), else as its preview (a TIFF image, a Windows metafile or an +//! EPSI bitmap), with a warning that names the error, the operator and the procedures it ran in; +//! without a preview, what was drawn before the error is kept (with that warning), else the file +//! is refused. mod ai; mod ate; @@ -201,6 +205,13 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), }; + // A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the + // prolog defining its operators without including it): its layers, which the program has. + let why = match editing_data.then(|| native::program_alone(ps)).flatten() { + Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }), + Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"), + None => why, + }; if let Some(p) = preview::preview(bytes, ps, frame) { let warnings = vec![format!("this file's PostScript couldn't be read ({why}): {}", p.what)]; return Ok(Imported { document: p.document, warnings, preview: true }); @@ -214,7 +225,7 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result out.warn("the file draws nothing"); Ok(finish(out)) } - Err(e) => Err(format!("this PostScript file can't be read ({}): save it as PDF or SVG in the app that made it", reason(&e, fault.as_ref()))), + Err(_) => Err(format!("this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it")), } } diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index d0aff0c0e..36b082fee 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -1,11 +1,15 @@ //! The layers of an Illustrator EPS or `.ai`. //! //! Besides the page it prints, such a file carries the app's own copy of the art: an EPS after its -//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). It has what the -//! page doesn't: the layers with their names and options, the groups, compound paths and clipping -//! groups, the objects' names, the hidden objects and layers, the art outside the artboards and -//! every artboard. `ai` reads that structure into a document, with the colours, gradients, -//! transparency and images of the art. +//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). A file in the +//! legacy format (a `.ai` in PostScript form: versions 3 to 8, and the apps that still write it) +//! is that copy itself: its program has the layers, and the prolog it names defines the operators +//! for printing (other apps name it without including it, so their files can't be run). +//! +//! The copy has what the page doesn't: the layers with their names and options, the groups, +//! compound paths and clipping groups, the objects' names, the hidden objects and layers, the art +//! outside the artboards and every artboard. `ai` reads that structure into a document, with the +//! colours, gradients, transparency and images of the art. //! //! Text objects are slots in that document: their characters, fonts and places are in the file's //! text document (see `ate`). Type that shows comes from the page, which draws it, into the slot @@ -21,6 +25,7 @@ //! //! Where a file doesn't match what this module expects, it is left alone and its page is imported. +use std::borrow::Cow; use std::collections::BTreeSet; use std::sync::Arc; @@ -991,12 +996,13 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result })) } -/// The EPS `visible` (read as its page) read through its editing copy instead: its layers (the +/// The EPS `visible` (read as its page) read through its editing copy instead (its private data, or +/// its program in the legacy format, see [`editing_copy`]): its layers (the /// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it /// has one that can't be used, `visible` with a warning that says why. pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported { - let Some(data) = ai::eps_data(ps) else { return visible }; + let Some(data) = editing_copy(ps) else { return visible }; let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false)); match result { Ok((document, mut warnings)) => { @@ -1023,7 +1029,7 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported { /// its PDF part). pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outlined: bool) -> (Document, Vec) { let Ok(data) = ai::decode_private(private) else { return (visible, warnings) }; - if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") { + if !has_layers(&data) { return (visible, warnings); } match build(&data, Some(&visible), Page::Artboard, outlined) { @@ -1047,6 +1053,27 @@ pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { build(&data, None, Page::Artboard, false) } +/// Does the editing data (or a program in the legacy format) have layers? +fn has_layers(data: &[u8]) -> bool { + data.windows(15).any(|w| w == b"%AI5_BeginLayer") +} + +/// The editing copy of the art in the PostScript `ps`: an EPS's private data, else the program +/// itself when it has layers (the legacy format). `None` without one. +fn editing_copy(ps: &[u8]) -> Option, String>> { + match ai::eps_data(ps) { + Some(data) => Some(data.map(Cow::Owned)), + None => has_layers(ps).then_some(Ok(Cow::Borrowed(ps))), + } +} + +/// A file in the legacy format whose page can't be read (a `.ai` in PostScript form that names its +/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the +/// document and the import's notes. `None` when the layers aren't in the program. +pub(super) fn program_alone(ps: &[u8]) -> Option), String>> { + (!ai::has_eps_data(ps) && has_layers(ps)).then(|| build(ps, None, Page::Art, false)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 356d9dc94..3c44e1a6c 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -640,6 +640,42 @@ fn hostile_executable_files_and_type_loops_are_refused() { blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor"); } +/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes +/// more than 20 s. +fn read_soon(body: &str) -> crate::Imported { + let (tx, rx) = std::sync::mpsc::channel(); + let ps = eps(body); + std::thread::spawn(move || tx.send(import(&ps)).ok()); + rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap() +} + +/// `arc` raises its second angle by multiples of 360 until it is at least the first, and `arcn` +/// lowers it until it is at most the first (PLRM 3rd ed., `arc` and `arcn` in chapter 8), however +/// large the angles: the arc runs between the angles' remainders after whole turns and ends at +/// the second angle, the new current point. +#[test] +fn arcs_with_huge_angles_end_at_their_second_angle() { + // `at(deg)` is the point at `deg` degrees on the circle of radius 10 around (50, 50), in + // document space. + let at = |deg: f64| Point::new(50.0 + 10.0 * deg.to_radians().cos(), 50.0 - 10.0 * deg.to_radians().sin()); + // Each program is listed with the remainders of its first and second angles after whole turns. + for (body, first, second) in [ + ("50 50 10 1e18 0 arc", 280.0, 0.0), + ("50 50 10 1e308 0 arc", 296.0, 0.0), + ("50 50 10 0 1e18 arcn", 0.0, 280.0), + ("50 50 10 0 1e308 arcn", 0.0, 296.0), + ] { + let r = read_soon(&format!("newpath {body} currentpoint stroke 1 1 rectfill")); + let o = objects(&r.document); + assert_eq!(o.len(), 2, "{body}: {:?}", r.warnings); + // The arc lies within one quarter of the circle, so its endpoints span its bounds. + let (a, b) = (at(first), at(second)); + assert!(near(bounds(&o[0]), Rect::from_points(a, b)), "{body}: {:?}", bounds(&o[0])); + // The second object is the square drawn at the current point. + assert!(near(bounds(&o[1]), Rect::new(b.x, b.y - 1.0, b.x + 1.0, b.y)), "{body}: {:?}", bounds(&o[1])); + } +} + #[test] fn cshow_runs_its_procedure_for_each_character() { check("/n 0 def { pop pop pop /n n 1 add def } (abc) cshow n 3 eq"); diff --git a/crates/eps/src/import/tests_illustrator.rs b/crates/eps/src/import/tests_illustrator.rs index a3124af1c..849917a75 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use vectorcraft_doc::clipnest::MAX_NEST; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind}; +use vectorcraft_geom::Rect; use crate::import::import; @@ -80,6 +81,80 @@ fn other_postscript_keeps_its_own_u() { assert_eq!(read("%%Creator: some app\n", "", &body).0, "p p"); } +/// A `.ai` in PostScript form as other apps write it (#1027): its header and prolog name the +/// procsets that define its operators (by the DSC resource comments) without including them, so +/// its program can't be run. Its layers come from the program itself: names as they are, the +/// legacy format's ten-operand `Lb`, flatness `i`, the bounding box as the page. +const NAMED_PROLOG: &str = "%!PS-Adobe-3.0\n%%Creator: a CAD app\n%%BoundingBox: 0 0 200 100\n\ + %%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%%+ procset Adobe_IllustratorA_AI3 1.0 0\n\ + %AI3_TemplateBox: 288 384 288 384\n%%EndComments\n%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\n\ + Adobe_packedarray /initialize get exec\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n\ + %%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(SECTION::Cut) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M []0 d\n0 D\n\ + 10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\nLB\n%AI5_EndLayer--\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(SECTION::Hatch) Ln\n0 A\n0 R\n0 0 0 1 K\n0.1 w\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\n\ + %%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\nAdobe_IllustratorA_AI3 /terminate get exec\n%%EOF\n"; + +#[test] +fn a_postscript_ai_that_names_its_prolog_without_including_it_opens_with_its_layers() { + let r = import(NAMED_PROLOG.as_bytes()).unwrap(); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let d = &r.document; + let names: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["SECTION::Cut", "SECTION::Hatch"]); + let colors: Vec<_> = d.layers.iter().map(|l| if let NodeKind::Layer { color, .. } = l.kind { Some(color) } else { None }).collect(); + assert_eq!(colors, [Some(LayerColor::Custom([0, 0, 0])), Some(LayerColor::Custom([191, 63, 255]))]); + assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "the page is the bounding box"); + let square = &d.layers[0].children().unwrap()[0]; + assert_eq!(square.geometric_bounds(), Some(Rect::new(10.0, 10.0, 90.0, 90.0))); + let stroke = square.appearance.stroke().unwrap(); + assert_eq!((stroke.width, stroke.cap), (0.8, LineCap::Round)); + assert_eq!(tree(d.layers[1].children().unwrap()), "p"); +} + +/// A `.ai` in PostScript form whose layers can't be read either says why, after what stopped its +/// program. +#[test] +fn a_postscript_ai_whose_layers_cant_be_read_says_why() { + let damaged = NAMED_PROLOG.replace("0 D\n", "0 D\n1 2 frobnicate\n"); + let e = import(damaged.as_bytes()).unwrap_err(); + assert!(e.contains("`Adobe_packedarray`") && e.contains("its layers couldn't be read either") && e.contains("`frobnicate`"), "{e}"); +} + +/// The editing text of a newer file saved as PostScript (no prolog at all, a non-printing setup): +/// it opens with its sublayers. +#[test] +fn newer_editing_text_as_postscript_opens_with_its_sublayers() { + let layer = + |name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 -1 79 128 255 0 50 0 Lb\n({name}) Ln\n0 A\n0 Xw\n{art}LB\n%AI5_EndLayer--\n"); + let ground = layer("Ground", &(layer("Lawn", "0 0 0 0.3 k\n10 20 m\n70 20 L\n70 80 L\n10 80 L\nf\n") + &layer("Paving", ""))); + let text = format!( + "%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 250 100\n%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 250 100\n%%EndComments\n\ + %%BeginProlog\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n{ground}%%PageTrailer\n%%Trailer\n%%EOF\n" + ); + let r = import(text.as_bytes()).unwrap(); + let d = &r.document; + assert_eq!(d.layers.len(), 1, "{:?}", r.warnings); + let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(subs, ["Lawn", "Paving"]); +} + +/// A file in the legacy format whose prolog is included (here one of our own) runs as its page, and +/// comes in through the layers its program has when they look like the page. +#[test] +fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() { + let prolog = "/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def"; + let body = format!("%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Back) Ln\n0 A\n0 0 0 1 k\n{} LB\n%AI5_EndLayer--\n", tri(10)); + let file = String::from_utf8(ai(AI8, "", &body)).unwrap().replace("%%EndProlog", &format!("{prolog}\n%%EndProlog")); + let r = import(file.as_bytes()).unwrap(); + let names: Vec<_> = r.document.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["Back"], "{:?}", r.warnings); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + // Without the editing data, it is its page. + let page = crate::import::import_with(file.as_bytes(), false).unwrap(); + assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1")); +} + #[test] fn unbalanced_and_runaway_groups_are_read_safely() { // Ends without a beginning end nothing; groups left open hold the rest. diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 312f1ddb8..bd8e4c32f 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -543,6 +543,33 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() { assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err()); } +/// A global process colour (`Xk`) or a pattern fill on a sublayer no longer sends a `.ai` to its +/// PDF part, which has only the top-level layers (#1025). +#[test] +fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() { + // The PDF part's yellow square, in a global yellow at full tint, on a sublayer; a hidden + // sublayer with a pattern fill (the page doesn't have it). + let yellow = "0 0 1 0 (Yellow global) 0 0 Xk\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"; + let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n"; + let patterned = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf"; + let ground = layer("Ground", true, &(layer("Lawn", true, yellow) + &layer("Paving", false, patterned))); + let art = native(&format!("{dots}{ground}")); + let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false); + assert_eq!(names(&d), ["Ground"], "{warnings:?}"); + let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| (l.name.clone().unwrap_or_default(), kinds(l))).collect(); + assert_eq!(subs, [("Lawn".to_string(), vec!["path"]), ("Paving".to_string(), vec!["path"])]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(d.swatch("Yellow global").is_some_and(|s| s.global && !s.spot)); + assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some()); + // Without a PDF part, the pattern fill shows. + let shown = native(&format!("{dots}{}", layer("Ground", true, &layer("Lawn", true, patterned)))); + let (d, notes) = crate::ai_alone(&private(&shown)).unwrap(); + let lawn = &d.layers[0].children().unwrap()[0]; + assert_eq!((lawn.name.as_deref(), kinds(lawn)), (Some("Lawn"), vec!["path"]), "{notes:?}"); + let fill = lawn.children().unwrap()[0].appearance.fill().map(|f| f.paint.clone()); + assert!(matches!(&fill, Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots"), "{fill:?}"); +} + #[test] fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() { // A 20-point square that the page doesn't draw: 4% of the page, and an object all the same. diff --git a/crates/geom/src/hit.rs b/crates/geom/src/hit.rs index 6115a96c4..6e4664a98 100644 --- a/crates/geom/src/hit.rs +++ b/crates/geom/src/hit.rs @@ -1,18 +1,60 @@ //! Hit testing primitives. -use kurbo::{BezPath, ParamCurveNearest, Point, Rect, Shape}; +use std::borrow::Cow; + +use kurbo::{BezPath, ParamCurveNearest, PathEl, Point, Rect, Shape}; use crate::path::{FillRule, PathData}; /// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled). pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool { - let w = path.winding(p); + let w = closed(path).winding(p); match rule { FillRule::NonZero => w != 0, FillRule::EvenOdd => w % 2 != 0, } } +/// `path` with each open subpath closed by a straight line back to its start, the edge its fill +/// has. kurbo's winding leaves that edge out, so a point beside it counted as outside the fill: +/// an open path lost its fill clicks on that side (a reflection moved the edge to the other one). +fn closed(path: &BezPath) -> Cow<'_, BezPath> { + let mut open = false; + let mut needs = false; + for el in path.elements() { + match el { + PathEl::MoveTo(_) => { + needs |= open; + open = true; + } + PathEl::ClosePath => open = false, + _ => {} + } + } + if !(needs || open) { + return Cow::Borrowed(path); + } + let mut out = BezPath::new(); + let mut open = false; + for &el in path.elements() { + match el { + PathEl::MoveTo(_) => { + if open { + out.close_path(); + } + open = true; + } + PathEl::ClosePath => open = false, + _ => {} + } + out.push(el); + } + if open { + out.close_path(); + } + Cow::Owned(out) +} + /// Distance from `p` to the nearest point on the path outline. pub fn distance_to_outline(path: &BezPath, p: Point) -> f64 { path.segments().map(|s| s.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt() @@ -77,6 +119,32 @@ mod tests { assert!(fill_contains(&bp, FillRule::EvenOdd, Point::new(1.0, 5.0))); } + /// An open path fills as if closed by a straight line back to its start, and is hit there, + /// whichever side that line falls on (a reflected open path lost its fill clicks). + #[test] + fn open_paths_fill_as_closed_on_either_side() { + // A filled sliver left open by a deleted anchor; its closing line runs down its left side. + let open = BezPath::from_svg( + "M86.311 69.612C100.544 78.261 110.506 88.64 110.506 100.748C110.506 111.127 100.544 118.046 86.311 118.046C93.214 102.045 91.407 102.403 91.407 97.61", + ) + .unwrap(); + let mut closed = open.clone(); + closed.close_path(); + for p in [Point::new(100.0, 100.0), Point::new(95.0, 85.0), Point::new(92.0, 112.0)] { + assert!(closed.winding(p) != 0, "{p:?} is inside the closed sliver"); + assert!(fill_contains(&open, FillRule::NonZero, p), "{p:?}: the open sliver's fill"); + assert!(fill_contains(&open, FillRule::EvenOdd, p), "{p:?}: even-odd too"); + } + assert!(!fill_contains(&open, FillRule::NonZero, Point::new(80.0, 100.0)), "left of the closing line"); + // Mirrored, the closing line is on the other side: still filled. + let flipped = kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, 200.0, 0.0]) * open.clone(); + assert!(fill_contains(&flipped, FillRule::NonZero, Point::new(100.0, 100.0))); + // Two open subpaths: each closes on its own. + let mut two = open.clone(); + two.extend(kurbo::Affine::translate((0.0, 100.0)) * open.clone()); + assert!(fill_contains(&two, FillRule::NonZero, Point::new(100.0, 100.0)) && fill_contains(&two, FillRule::NonZero, Point::new(100.0, 200.0))); + } + #[test] fn marquee() { let p = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 64db36827..d123fc4e8 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -235,8 +235,9 @@ pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec { let f = i as f64 / (concentric + 1) as f64; out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f)))); } - for i in 0..radial.max(1) { - let a = -PI / 2.0 + TAU * i as f64 / radial.max(1) as f64; + // No dividers at 0 (the loop never divides by it). + for i in 0..radial { + let a = -PI / 2.0 + TAU * i as f64 / radial as f64; out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin()))); } out @@ -402,6 +403,10 @@ mod tests { fn grids() { assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11); assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11); + // #1008: no radial dividers is none, only the outer ellipse without concentric ones. + for (radial, len) in [(0, 1), (1, 2), (2, 3)] { + assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, radial).len(), len, "{radial}"); + } } #[test] diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 25639ebd5..17b3773a8 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -312,6 +312,22 @@ fn inspect_document_reports_the_paint_of_type() { assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}"); } +/// #1003: type made on or in a path takes add_text's `color` too, over the current fill. +#[test] +fn add_text_on_or_in_a_path_takes_its_color() { + for mode in ["onPath", "area"] { + let mut s = server(); + let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 40, "y": 40, "width": 180, "height": 60, "fill": "#0000ff"})); + let path = serde_json::from_str::(&text_of(&r)).unwrap()["id"].clone(); + let r = call(&mut s, 3, "add_text", json!({"text": "Hello", "path": path, "mode": mode, "color": "#ff0000"})); + assert_eq!(r["isError"], false, "{r}"); + let id = serde_json::from_str::(&text_of(&r)).unwrap()["id"].clone(); + let v: Value = serde_json::from_str(&text_of(&call(&mut s, 4, "inspect_document", json!({})))).unwrap(); + let t = v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).cloned().unwrap(); + assert_eq!(t["fill"].as_str(), Some("#ff0000"), "{mode}: {t}"); + } +} + /// save_file says it saves in the document's own format or the one the path's extension picks. #[test] fn save_file_describes_the_formats_it_writes() { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b84358ce3..9ae42a4af 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -574,7 +574,7 @@ fn select_ids(b: &mut dyn Backend, a: &Args) -> Result<(), String> { fn add_text(b: &mut dyn Backend, a: &Args) -> Result { let text = a.get("text").and_then(Value::as_str).ok_or("missing string argument `text`")?; let mut style = json!({}); - for k in ["size", "font"] { + for k in ["size", "font", "color"] { if let Some(v) = a.get(k) { style[k] = v.clone(); } @@ -592,9 +592,6 @@ fn add_text(b: &mut dyn Backend, a: &Args) -> Result { for (k, v) in style.as_object().into_iter().flatten() { p[k.as_str()] = v.clone(); } - if let Some(c) = a.get("color") { - p["color"] = c.clone(); - } if let (Some(w), Some(h)) = (a.get("width"), a.get("height")) { p["area"] = json!({"width": w, "height": h}); } diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 677424c77..d3143c8c8 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -235,12 +235,16 @@ pub(crate) struct Tidy { keep: Vec, /// Input curves (with inflated bounds) used to rebuild split pieces exactly. curves: Vec<(kurbo::Rect, CubicBez)>, + /// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose + /// bounds can hold a point, without testing them all. + by_x: Vec, + max_width: f64, } impl Tidy { /// Merge any smooth joint. pub(crate) fn free(precision: f64) -> Self { - Self { precision, keep: Vec::new(), curves: Vec::new() } + Self { precision, ..Default::default() } } /// Keep every on-curve point of `paths`. pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator) -> Self { @@ -257,7 +261,10 @@ impl Tidy { } } keep.sort_by(|a, b| a.x.total_cmp(&b.x)); - Self { precision, keep, curves } + let mut by_x: Vec = (0..curves.len()).collect(); + by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0)); + let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max); + Self { precision, keep, curves, by_x, max_width } } fn is_kept(&self, p: Point) -> bool { let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs())); @@ -322,10 +329,11 @@ impl Arrangement { pub(crate) fn contours_to_path(c: &Contours, idx: impl IntoIterator, tidy: &Tidy) -> PathData { let mut subpaths = Vec::new(); for i in idx { - if is_sliver(&c[i].path, tidy.precision) { + let path = tidy.repair(&c[i].path); + if is_sliver(&path, tidy.precision) { continue; } - if let Some(sp) = tidy_bezpath(&c[i].path, tidy) { + if let Some(sp) = tidy_bezpath(&path, tidy) { subpaths.push(sp); } } @@ -515,6 +523,84 @@ fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option { None } +impl Tidy { + /// The input curves whose bounds hold both `a` and `b`. + fn curves_around(&self, a: Point, b: Point) -> impl Iterator { + let x = a.x.min(b.x); + let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x); + let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width); + self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c) + } + + /// `bp` with each curve piece that strays from the one input curve through both its ends + /// rebuilt as that curve's exact sub-curve. linesweeper 0.5 can emit a piece that doesn't + /// follow its curve; out and back between two points of one curve, such pieces are a lens + /// instead of nothing (issue #1028). A piece on a curve, or whose ends two different input + /// curves pass through (a real lens between them), is left as it is. + pub(crate) fn repair(&self, bp: &BezPath) -> BezPath { + use kurbo::{ParamCurve, ParamCurveNearest, PathEl}; + if self.curves.is_empty() { + return bp.clone(); + } + let mut out = BezPath::new(); + let mut cur = Point::ZERO; + let mut start = Point::ZERO; + for el in bp.iter() { + let piece = match el { + PathEl::QuadTo(c, p) => Some(kurbo::QuadBez::new(cur, c, p).raise()), + PathEl::CurveTo(c1, c2, p) => Some(CubicBez::new(cur, c1, c2, p)), + _ => None, + }; + match (el, piece) { + (_, Some(raw)) => { + let (a, b) = (raw.p0, raw.p3); + let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs()); + let tol = 1e-6 * scale; + let mut subs: Vec = vec![]; + let mut follows = false; + for c in self.curves_around(a, b) { + let (na, nb) = (c.nearest(a, 1e-12), c.nearest(b, 1e-12)); + if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol || (na.t - nb.t).abs() < 1e-12 { + continue; + } + if [0.25, 0.5, 0.75].iter().all(|&t| c.nearest(raw.eval(t), 1e-12).distance_sq.sqrt() <= tol * 10.0) { + follows = true; + break; + } + let sub = if na.t < nb.t { + c.subsegment(na.t..nb.t) + } else { + let r = c.subsegment(nb.t..na.t); + CubicBez::new(r.p3, r.p2, r.p1, r.p0) + }; + subs.push(CubicBez::new(a, sub.p1, sub.p2, b)); + } + let same = |p: &CubicBez, q: &CubicBez| p.p1.distance(q.p1) <= tol * 10.0 && p.p2.distance(q.p2) <= tol * 10.0; + match subs.split_first() { + Some((sub, rest)) if !follows && rest.iter().all(|q| same(sub, q)) => out.curve_to(sub.p1, sub.p2, b), + _ => out.push(el), + } + cur = b; + } + (PathEl::MoveTo(p), None) => { + out.push(el); + (cur, start) = (p, p); + } + (PathEl::LineTo(p), None) => { + out.push(el); + cur = p; + } + (PathEl::ClosePath, None) => { + out.push(el); + cur = start; + } + (_, None) => out.push(el), + } + } + out + } +} + /// Resolve self-intersections / overlaps of a single filled path into simple, consistently /// oriented contours (outer contours and holes). pub fn normalize(path: &PathData, rule: FillRule) -> PathData { @@ -592,3 +678,57 @@ pub fn area(path: &PathData, rule: FillRule) -> f64 { Err(_) => 0.0, } } + +#[cfg(test)] +mod tests { + use kurbo::ParamCurve; + + use super::*; + + /// A quarter-circle-like arc, the input curve the pieces below are cut from. + fn arc() -> CubicBez { + CubicBez::new((0.0, 0.0), (0.0, 55.0), (45.0, 100.0), (100.0, 100.0)) + } + + #[test] + fn a_stray_piece_out_and_back_along_one_curve_is_no_outline() { + // Issue #1028: linesweeper 0.5 handed back a contour running out along an input curve and + // back by a piece that doesn't follow it, which refit into a visible lens. + let c = arc(); + let tidy = + Tidy::keeping(DEFAULT_PRECISION, [&BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)])]); + let out = c.subsegment(0.2..0.6); + let mut contour = BezPath::new(); + contour.move_to(out.p0); + contour.curve_to(out.p1, out.p2, out.p3); + // back to the start, bulging off the curve and overshooting the end like the reported piece + contour.curve_to(out.p0 + (out.p0 - out.p3) * 0.15 + kurbo::Vec2::new(4.0, -3.0), out.p1 + kurbo::Vec2::new(3.0, -2.0), out.p0); + contour.close_path(); + assert!(!is_sliver(&contour, tidy.precision), "the stray piece makes a lens"); + let fixed = tidy.repair(&contour); + assert!(is_sliver(&fixed, tidy.precision), "{}", fixed.to_svg()); + // a piece on its curve is left exactly as it is + let mut on = BezPath::new(); + on.move_to(out.p0); + on.curve_to(out.p1, out.p2, out.p3); + on.line_to(out.p0); + on.close_path(); + assert_eq!(tidy.repair(&on), on); + } + + #[test] + fn a_lens_between_two_curves_through_the_same_points_stays() { + // Two input curves meeting at both ends bound a real face: neither piece is "repaired" + // onto the other. + let lower = CubicBez::new((0.0, 0.0), (30.0, 20.0), (70.0, 20.0), (100.0, 0.0)); + let upper = CubicBez::new((0.0, 0.0), (30.0, -20.0), (70.0, -20.0), (100.0, 0.0)); + let input = |c: CubicBez| BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)]); + let tidy = Tidy::keeping(DEFAULT_PRECISION, [&input(lower), &input(upper)]); + let mut lens = BezPath::new(); + lens.move_to(lower.p0); + lens.curve_to(lower.p1, lower.p2, lower.p3); + lens.curve_to(upper.p2, upper.p1, upper.p0); + lens.close_path(); + assert_eq!(tidy.repair(&lens), lens); + } +} diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index 623ffbbb1..548546d8d 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -192,4 +192,29 @@ mod tests { // Ring 16·20 − 8·12 = 224, plus the bar outside it (10·4) and inside its hole (6·4). assert!((area - (224.0 + 40.0 + 24.0)).abs() < 1e-6, "{area}"); } + + #[test] + fn miter_offset_finishes_where_the_sweep_used_to_loop() { + // Issue #1028: with linesweeper 0.4 the sweep over this miter offset never finished and + // its memory grew without bound, freezing the app. A synthetic outline (found by search) + // with the same failure as the reported glyph; the exact coordinates matter. + const OUTLINE: &str = "M74.359137297823,82.83738194552835 L88.097432485874,103.04979803227248 \ + L67.06936731196308,105.95779886823588 L53.29382873396223,98.53943925153727 \ + L29.684713319694094,94.17996020306241 L41.65480824807079,75.639672165115 \ + Q47.491034578751446,52.12555333014565 46.157302824282816,55.67193079127421 \ + Q69.08365151786553,53.9608681393331 68.37499772324333,51.17242466680129 \ + Q74.4136309339628,75.13284223919301 71.3309887459222,73.69128869058812 Z"; + let path = PathData::from_bezpath(&BezPath::from_svg(OUTLINE).unwrap()); + let (tx, rx) = std::sync::mpsc::channel(); + let input = path.clone(); + std::thread::spawn(move || tx.send(offset_path(&input, 1.8350459854022054, Join::Miter, 4.0))); + let Ok(out) = rx.recv_timeout(std::time::Duration::from_secs(30)) else { + // the stuck sweep keeps allocating on its thread: end the test binary now + eprintln!("Offset Path didn't finish in 30 s (issue #1028)"); + std::process::exit(101); + }; + let (before, after) = (crate::area(&path, FillRule::NonZero), crate::area(&out, FillRule::NonZero)); + assert_eq!(out.subpaths.len(), 1); + assert!(after > before, "{before} → {after}"); + } } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 7a5b3ab89..a8a39ebaf 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -21,7 +21,7 @@ use vectorcraft_geom::{FillRule, PathData}; use crate::import_color::{Colors, Native}; use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover}; -use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key}; +use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key}; use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient}; use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright}; use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs}; @@ -692,8 +692,9 @@ struct Builder<'p> { /// Mask keys of CMYK images whose mask turned out to hide nothing. opaque: HashSet, warnings: Vec, - /// Fonts by cache key → base font name (from [`scan_page`]). - fonts: HashMap, + /// Fonts by cache key → base font name and whether the file carries its glyphs (from + /// [`scan_page`]). + fonts: FontNames, font_names: HashMap, families: Option, missing_fonts: Vec, @@ -1346,7 +1347,7 @@ impl<'p> Builder<'p> { return n.clone(); } let data = o.font_data(); - let name = self.fonts.get(&key).cloned().or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone())); + let name = self.fonts.get(&key).map(|(n, _)| n.clone()).or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone())); let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic)); // A subset's six-letter tag. let ps = name.as_deref().map(|n| match n.split_once('+') { @@ -1429,7 +1430,12 @@ impl<'p> Builder<'p> { let upright = width.is_some_and(|w| w.abs() <= 1.0); let top = m * kurbo::Point::new(f64::from(width.filter(|w| *w > 1.0).unwrap_or(1000.0)) * 0.5, 880.0); let mut info = self.font_name(key, o); - if let Some(face) = &info.face { + // A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which + // style of the installed family it is, so the name decides. + let embedded = self.fonts.get(&key).is_none_or(|(_, e)| *e); + if let Some(face) = &info.face + && embedded + { let decided = match self.matched.get(&key) { Some(m) => m.clone(), // A glyph that can't be compared (several characters, a vertical form) decides diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs index c4966fbe8..f38eadb1b 100644 --- a/crates/pdf/src/import_color.rs +++ b/crates/pdf/src/import_color.rs @@ -389,12 +389,16 @@ fn alt_model(o: &Object<'_>) -> Option { Object::Array(a) => { let mut it = a.flex_iter(); match it.next::>()?.as_str() { - "ICCBased" => match it.next::>()?.dict().get::(b"N")? { - 1 => Some(Alt::Gray), - 3 => Some(Alt::Rgb), - 4 => Some(Alt::Cmyk), - _ => None, - }, + "ICCBased" => { + let profile = it.next::>()?; + match profile.dict().get::(b"N")? { + 1 => Some(Alt::Gray), + 3 if lab_profile(&profile) => Some(Alt::Lab), + 3 => Some(Alt::Rgb), + 4 => Some(Alt::Cmyk), + _ => None, + } + } "Lab" => Some(Alt::Lab), "CalRGB" => Some(Alt::Rgb), "CalGray" => Some(Alt::Gray), @@ -406,6 +410,27 @@ fn alt_model(o: &Object<'_>) -> Option { } } +/// Is the ICC profile of an `ICCBased` space a Lab one (its alternate space `Lab`, else its header's +/// data colour space `Lab `)? Spot colours defined in Lab come with such a profile as their +/// alternate space (#1032), and their tint transforms give Lab values. +fn lab_profile(profile: &Stream<'_>) -> bool { + use std::io::Read; + if let Some(Object::Array(a)) = profile.dict().get::>(b"Alternate") { + return a.flex_iter().next::>().is_some_and(|n| n.as_str() == "Lab"); + } + // The header's first 20 bytes, without decoding the rest of the profile. + let raw = profile.raw_data(); + let mut head = vec![]; + match profile.filters().as_slice() { + [] => head.extend(raw.iter().take(20)), + [hayro_syntax::Filter::FlateDecode] => { + let _ = flate2::read::ZlibDecoder::new(&*raw).take(20).read_to_end(&mut head); // a damaged profile reads as not Lab + } + _ => return false, + } + head.get(16..20) == Some(b"Lab ") +} + /// An alternate-space colour (the tint transform's output) as a VectorCraft colour. fn alt_color(alt: Alt, v: &[f32]) -> Option { let c = |i: usize| v.get(i).copied().filter(|x| x.is_finite()); diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index d2ec2e810..667274e88 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -248,7 +248,7 @@ struct Walker<'w, 'p> { /// The groups that are off for the interpreter (none once [`all_on`] turned them on). off: HashSet, xref: &'p hayro_syntax::xref::XRef, - fonts: &'w mut HashMap, + fonts: &'w mut FontNames, visible: Vec, ops: usize, max_ops: usize, @@ -296,7 +296,7 @@ impl Walker<'_, '_> { if let Some(f) = res.fonts.get::>(key.as_ref()) && let Some(name) = f.get::>(b"BaseFont") { - self.fonts.entry(f.cache_key()).or_insert_with(|| String::from_utf8_lossy(name.as_ref()).into_owned()); + self.fonts.entry(f.cache_key()).or_insert_with(|| (String::from_utf8_lossy(name.as_ref()).into_owned(), embeds_glyphs(&f))); } } } @@ -372,13 +372,32 @@ impl Walker<'_, '_> { } } +/// The fonts of a document by cache key: base font name, and whether the file carries the glyphs +/// ([`embeds_glyphs`]). +pub(crate) type FontNames = HashMap; + +/// Whether font `f` carries its own glyphs: a font program in its descriptor (a composite font's in +/// its descendant's), or a Type 3 font's glyph procedures. A font that doesn't is drawn with a +/// stand-in, whose glyphs say nothing about which installed face the document means. +fn embeds_glyphs(f: &Dict<'_>) -> bool { + if f.get::>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3") { + return true; + } + // A font program is a stream, so always an indirect object. + let program = |d: &Dict<'_>| { + d.get::>(b"FontDescriptor").is_some_and(|fd| [&b"FontFile"[..], b"FontFile2", b"FontFile3"].iter().any(|k| fd.get_ref(*k).is_some())) + }; + program(f) || f.get::>(b"DescendantFonts").and_then(|a| a.iter::>().next()).is_some_and(|d| program(&d)) +} + /// Walk `page` as the interpreter will: `all_on` when [`all_on`] turned every group on. Fonts -/// found are noted in `fonts` (their cache key → base font name). -pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut HashMap) -> Scan { +/// found are noted in `fonts` (their cache key → base font name, and whether the file carries its +/// glyphs). +pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut FontNames) -> Scan { walk_page(page, ocgs, all_on, fonts).out } -fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap) -> Walker<'w, 'p> { +fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames) -> Walker<'w, 'p> { let off = if all_on { HashSet::new() } else { ocgs.off.clone() }; let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, max_ops: max_ops(), out: Scan::default(), hidden_form: false }; w.walk(page.typed_operations(), page.resources(), 0); diff --git a/crates/pdf/src/tests_import_color.rs b/crates/pdf/src/tests_import_color.rs index 4a0acc4de..d9d690ce2 100644 --- a/crates/pdf/src/tests_import_color.rs +++ b/crates/pdf/src/tests_import_color.rs @@ -215,3 +215,33 @@ fn colour_spaces_are_read_from_the_interpreters_debug_form() { assert_eq!((space, comps), ("ColorSpace(DeviceCmyk)", vec![0.101960786, 0.2, 0.3, 0.4])); assert_eq!(crate::import_color::split_color("Color { color_space: X, components: [NaN], opacity: 1.0 }"), None); } + +/// Spot colours defined in Lab can have an ICC Lab profile as their alternate space, `[/ICCBased +/// profile]` with 3 components: the tint transform gives Lab values, which are not RGB (#1032). The +/// profile is told by its `Alternate` (`[/Lab …]`), else by its header's data colour space. +#[test] +fn spot_colours_with_an_icc_lab_alternate_keep_their_lab_values() { + let f = first_extra(1); + // A profile's first 20 bytes: size, CMM, version, class, then the data colour space. + let profile = |head: &str, alternate: &str| format!("<< /N 3 {alternate} /Length {} >>\nstream\n{head}\nendstream", head.len()); + let lab_header = profile("0000none0000spacLab XYZ ", ""); + let lab_alternate = profile("0000none0000spacXYZ XYZ ", "/Alternate [/Lab << /WhitePoint [0.9642 1 0.8249] /Range [-128 127 -128 127] >>]"); + let rgb = profile("0000none0000mntrRGB XYZ ", ""); + let tint = |c1: &str| format!("<< /FunctionType 2 /Domain [0 1] /C0 [100 0 0] /C1 [{c1}] /N 1 >>"); + let spaces = format!( + "/CS0 [/Separation /Plum [/ICCBased {f} 0 R] {}] /CS1 [/Separation /Sky [/ICCBased {} 0 R] {}] /CS2 [/Separation /Leafy [/ICCBased {} 0 R] << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0.5 0.9 0.1] /N 1 >>]", + tint("32.9412 45 -18"), + f + 1, + tint("51 -26 -45"), + f + 2 + ); + let page = page_with(&spaces, &[("CS0", "1"), ("CS1", "0.5"), ("CS2", "1")]); + let d = import(&pdf_with(&[page], &[&lab_header, &lab_alternate, &rgb], None)).unwrap(); + let f = fills(&d); + assert_eq!(solid(&f[0]).1, Some("Plum")); + assert!(near(d.swatch("Plum").unwrap().paint.color().unwrap(), Color::lab(32.941, 45.0, -18.0)), "{:?}", d.swatch("Plum")); + assert_eq!((solid(&f[1]).1, solid(&f[1]).2), (Some("Sky"), 0.5)); + assert!(near(d.swatch("Sky").unwrap().paint.color().unwrap(), Color::lab(51.0, -26.0, -45.0)), "{:?}", d.swatch("Sky")); + // An RGB profile stays RGB. + assert!(near(d.swatch("Leafy").unwrap().paint.color().unwrap(), Color::rgb(0.5, 0.9, 0.1)), "{:?}", d.swatch("Leafy")); +} diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode/mod.rs index 42b60adbb..98ccf0b88 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -97,6 +97,8 @@ impl RasterExportOptions { anti_alias: self.anti_alias, // Opaque art stays exactly opaque where translucent edges cross it (#787). precise: true, + // Shapes meeting edge to edge leave no seam (#983). + supersample: true, ..Default::default() } } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 38bc06bec..266bfe0b5 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -61,6 +61,7 @@ pub(crate) fn has_object_fx(n: &Node) -> bool { || effects::has_container_appearance(n) || effects::has_crop_marks(n) || effects::has_adjustment(n) + || effects::has_revolve(n) } /// Is `n` a group or layer (whose evaluated art keeps its knockout setting)? @@ -123,6 +124,12 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] { /// Visual bounds including geometry effects, stroke outsets and shadows/glows. pub(crate) fn visual_bounds(n: &Node) -> Option { + if let Some(art) = effects::revolve_art(n) { + return cull_bounds(&art).map(|b| { + let o = effects::outset(&n.appearance.effects, b); + b.inflate(o, o) + }); + } let bp = node_bezpath(n)?; let ctx = GeomContext::of(n); let g = effected_path(n, &bp); @@ -408,12 +415,13 @@ impl Renderer { _ => None, }; let container = is_container(a); - let art = match effects::crop_marks_art(a).or_else(|| effects::reshape(a, symbol.as_ref())) { + let art = match effects::crop_marks_art(a).or_else(|| effects::revolve_art(a)).or_else(|| effects::reshape(a, symbol.as_ref())) { Some(r) => r, None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()), None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(), None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; + let art = if effects::has_revolve(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art }; let mut art = self.adjusted_art(doc, art, effects::color_map(&a.appearance.effects).as_ref()); // A path's geometry effects still apply when it is drawn. let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 52b1f899c..748d32e15 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -114,6 +114,11 @@ pub struct RenderOptions { /// opaque, where 8-bit compositing leaves alpha 254 on some anti-aliased edges (#787). Off, /// the faster 8-bit pipeline (the canvas). pub precise: bool, + /// Exports: Art Optimized edges are supersampled ([`supersample_factor`]): the art is drawn + /// with hard edges at several times the size and averaged down, so shapes that meet edge to + /// edge leave no seam of the background between them (#983). Off, edges are smoothed shape by + /// shape (the canvas). + pub supersample: bool, } /// The opacity of the art around an isolated group or layer. @@ -186,10 +191,48 @@ impl Default for RenderOptions { smooth_images: true, isolated: None, precise: false, + supersample: false, } } } +/// The most pixels a supersampled render draws (the frame and its compositing buffers hold a few +/// times this many bytes). +const MAX_SUPERSAMPLED_PIXELS: u64 = 40_000_000; + +/// How many times wider and taller a `w`×`h` render is drawn when supersampled: 4, or less where +/// that would pass [`MAX_SUPERSAMPLED_PIXELS`] or the renderer's 65,535-pixel side; `None` when not +/// even twice fits (the edges are then smoothed shape by shape). +pub fn supersample_factor(w: u32, h: u32) -> Option { + (2..=4u32).rev().find(|k| { + let (sw, sh) = (u64::from(w) * u64::from(*k), u64::from(h) * u64::from(*k)); + sw <= u64::from(u16::MAX) && sh <= u64::from(u16::MAX) && sw * sh <= MAX_SUPERSAMPLED_PIXELS + }) +} + +/// Average each `k`×`k` block of `src` (`w`×`h` pixels of 4 premultiplied bytes) into one pixel. +fn downsample(src: &[u8], w: usize, h: usize, k: usize) -> Vec { + let (ow, oh) = (w / k.max(1), h / k.max(1)); + let n = (k * k).max(1) as u32; + let mut out = vec![0u8; ow * oh * 4]; + for (o, px) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let (ox, oy) = (o % ow.max(1), o / ow.max(1)); + let mut acc = [0u32; 4]; + for y in oy * k..(oy + 1) * k { + let row = y * w + ox * k; + for p in src.get(row * 4..(row + k) * 4).unwrap_or_default().as_chunks::<4>().0 { + for (a, v) in acc.iter_mut().zip(p) { + *a += u32::from(*v); + } + } + } + for (d, a) in px.iter_mut().zip(acc) { + *d = ((a + n / 2) / n).min(255) as u8; + } + } + out +} + /// A rendered image (premultiplied RGBA8, row-major). pub struct Rendered { pub width: u32, @@ -476,6 +519,16 @@ impl Renderer { /// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered { let start = now(); + if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() { + let (w, h) = (width.clamp(1, u16::MAX as u32), height.clamp(1, u16::MAX as u32)); + if let Some(k) = supersample_factor(w, h) { + let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() }; + let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks); + let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize); + self.stats.micros = now().saturating_sub(start); + return Rendered { width: w, height: h, pixels }; + } + } let prepared = proof::prepare(doc, opts); let doc: &Document = &prepared; let w = width.clamp(1, u16::MAX as u32) as u16; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b9c4692b6..b94ce1f3b 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -385,3 +385,41 @@ fn images_take_their_nearest_pixel_without_smoothing() { assert!(greys(true) >= 4, "smooth: a ramp from black to white"); assert_eq!(greys(false), 0, "nearest: black, then white"); } + +/// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no +/// seam of the white background between them, where shape-by-shape smoothing shows one; the outer +/// edges stay smooth. +#[test] +fn supersampled_art_leaves_no_seam_between_shapes_that_meet() { + let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + // Two triangles sharing the diagonal of the square (10, 10)–(90, 90). + for pts in [[(10.0, 10.0), (90.0, 10.0), (90.0, 90.0)], [(10.0, 10.0), (90.0, 90.0), (10.0, 90.0)]] { + let mut b = vectorcraft_geom::BezPath::new(); + b.move_to(pts[0]); + b.line_to(pts[1]); + b.line_to(pts[2]); + b.close_path(); + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), Appearance::basic(red(), Paint::None, 0.0))).unwrap(); + } + let draw = |supersample: bool| { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), precise: true, supersample, ..Default::default() }; + Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts) + }; + // The palest pixel on the shared diagonal, away from the corners. + let palest = |r: &Rendered| (20..80).map(|i| r.pixel(i, i)[1].max(r.pixel(i + 1, i)[1])).max().unwrap(); + assert!(palest(&draw(false)) > 20, "shape-by-shape smoothing shows the seam"); + assert_eq!(palest(&draw(true)), 0, "supersampled: no seam"); + // The outer edge is still smoothed: a pixel half covered is between white and red. + let ss = draw(true); + let edge = ss.pixel(50, 9); + assert!(edge == [255, 255, 255, 255] || (edge[1] > 0 && edge[1] < 255) || ss.pixel(50, 10)[1] < 255, "{edge:?}"); + // The factor fits the size and the renderer's limits. + assert_eq!(supersample_factor(100, 100), Some(4)); + assert_eq!(supersample_factor(2000, 2000), Some(3)); + assert_eq!(supersample_factor(3000, 3000), Some(2)); + assert_eq!(supersample_factor(5000, 5000), None); + assert_eq!(supersample_factor(40_000, 10), None); +} diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 1026829ec..9041b1076 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -40,6 +40,69 @@ fn lum(p: [u8; 4]) -> u32 { const WHITE: u32 = 765; +#[test] +fn revolve_canvas_matches_baked_art_and_shades_its_surface() { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((50.0, 15.0)); + bp.line_to((75.0, 30.0)); + bp.line_to((75.0, 75.0)); + bp.line_to((50.0, 90.0)); + let mut n = Node::path( + NodeId(0), + vectorcraft_geom::PathData::from_bezpath(&bp), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.2, 0.1)), Paint::None, 0.0), + ); + n.appearance.effects.push(fx("threeD.revolve", json!({"rotationX":0,"rotationY":0}))); + let d = doc_with(Document::new(100.0, 100.0), n); + let live = render(&d); + let baked = vectorcraft_effects::bake_document(&d).unwrap(); + let exported = render(&baked); + assert_eq!(live.pixels, exported.pixels, "canvas and baked/exported faces agree"); + let reds: Vec<_> = + live.pixels.as_chunks::<4>().0.iter().filter(|p| p[0] as u16 > p[1] as u16 * 2 && p[0] as u16 > p[2] as u16 * 2).map(|p| p[0]).collect(); + assert!(reds.len() > 1500, "a filled surface, rather than the open source path"); + assert!(reds.iter().max().unwrap() - reds.iter().min().unwrap() > 80, "lighting varies across the surface"); + assert_eq!(live.pixels, render_threads(&d, 4).pixels); +} + +#[test] +fn revolve_visible_surface_expansion_preserves_the_view_and_transparent_art() { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((50.0, 15.0)); + bp.curve_to((80.0, 25.0), (80.0, 75.0), (50.0, 90.0)); + let mut n = Node::path( + NodeId(0), + vectorcraft_geom::PathData::from_bezpath(&bp), + Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0), + ); + for (angle, rotation, perspective, opacity) in + [(360.0, 0.0, 0.0, 1.0), (360.0, -35.0, 55.0, 1.0), (150.0, -28.0, 40.0, 1.0), (360.0, -35.0, 55.0, 0.5)] + { + n.opacity = opacity; + n.appearance.effects = vec![fx( + "threeD.revolve", + json!({"angle":angle,"rotationX":rotation,"rotationY":25,"perspective":perspective,"segments":32,"expandVisibleOnly":true}), + )]; + let d = doc_with(Document::new(100.0, 100.0), n.clone()); + let live = render(&d); + let mut expanded = d.clone(); + let id = expanded.layers[0].children().unwrap()[0].id; + let source = expanded.node(id).unwrap().clone(); + let mut stroke = |_: &mut Document, _: &vectorcraft_geom::PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None; + let art = vectorcraft_effects::expand_leaf(&mut expanded, &source, &mut stroke).unwrap(); + *expanded.node_mut(id).unwrap() = art; + let flat = render(&expanded); + let difference: u64 = live.pixels.iter().zip(&flat.pixels).map(|(a, b)| a.abs_diff(*b) as u64).sum(); + let average = difference as f64 / live.pixels.len() as f64; + assert!(average < 1.0, "appearance differs by {average} channel levels: angle {angle}, rotation {rotation}, opacity {opacity}"); + if opacity < 1.0 { + assert_eq!(live.pixels, flat.pixels); + } + // Export baking keeps every face, even when the expansion checkbox is checked. + assert_eq!(live.pixels, render(&vectorcraft_effects::bake_document(&d).unwrap()).pixels); + } +} + /// A 30×30 red image placed at (20, 20). fn image_doc(effects: Vec) -> Document { let mut d = Document::new(100.0, 100.0); diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index f98092b65..8775a97d8 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -109,15 +109,19 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { // Manual kerning replaces the font's pair kerning. p.push(("font-kerning", "none".into())); } - if !st.features.is_empty() { - let v: Vec = st - .features - .iter() - .map(|t| match t.strip_prefix('-') { - Some(off) => format!("\"{off}\" 0"), - None => format!("\"{t}\" 1"), - }) - .collect(); + let mut v: Vec = st + .features + .iter() + .map(|t| match t.strip_prefix('-') { + Some(off) => format!("\"{off}\" 0"), + None => format!("\"{t}\" 1"), + }) + .collect(); + // Proportional Metrics: `palt` (SVG writes vertical type as outlines). + if st.proportional_metrics { + v.push("\"palt\" 1".into()); + } + if !v.is_empty() { p.push(("font-feature-settings", v.join(", "))); } match (st.underline, st.strikethrough) { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index d03e29aa4..a35292785 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -47,8 +47,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec Result<(Document, Vec Result<(Document, Vec| matches!(u, Some(U::In | U::Cm | U::Mm | U::Pt | U::Pc)); let (w, h) = (unit("width"), unit("height")); - let k = |u| if physical(u) { PT_PER_IN / CSS_PX_PER_IN } else { 1.0 }; - let dpi = if physical(w) || physical(h) { CSS_PX_PER_IN } else { PT_PER_IN }; + let (dpi, k) = if physical(w) || physical(h) { (CSS_PX_PER_IN, PT_PER_IN / CSS_PX_PER_IN) } else { (PT_PER_IN, 1.0) }; let unit = match w { Some(U::Mm) => Unit::Millimeters, Some(U::Cm) => Unit::Centimeters, @@ -238,7 +239,7 @@ impl RootUnits { Some(U::Pc) => Unit::Picas, _ => Unit::Pixels, }; - Self { dpi, k: (k(w), k(h)), unit } + Self { dpi, k, unit } } } diff --git a/crates/svg/src/import/spread.rs b/crates/svg/src/import/spread.rs index b88ecf3e7..fa4f70159 100644 --- a/crates/svg/src/import/spread.rs +++ b/crates/svg/src/import/spread.rs @@ -2,7 +2,7 @@ //! repeated (every other period mirrored, for reflect) as many times as the painted area needs, //! and the gradient stretched over those periods. -use vectorcraft_color::{GradientGeom, GradientKind, GradientStop}; +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop}; use vectorcraft_geom::{Point, Rect}; /// Most periods a gradient is expanded to. @@ -80,17 +80,9 @@ fn whole_period(stops: &[GradientStop]) -> Vec { /// A period mirrored: the stops in reverse, each midpoint measured from the other side. fn mirror(period: &[GradientStop]) -> Vec { - let rev: Vec<&GradientStop> = period.iter().rev().collect(); - rev.iter() - .enumerate() - .map(|(i, s)| GradientStop { - offset: 1.0 - s.offset, - // The midpoint from this stop to the next one is the mirror of the one the next stop - // had towards this one. - midpoint: rev.get(i + 1).map_or(0.5, |next| 1.0 - next.midpoint), - ..(*s).clone() - }) - .collect() + let mut g = Gradient { kind: GradientKind::Linear, stops: period.to_vec() }; + g.reverse(); + g.stops } #[cfg(test)] diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index aae41bdab..3789d8f4a 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -41,8 +41,9 @@ //! * One CSS pixel (user unit) is one point, as on export, and absolute lengths (`in`, `cm`, `mm`, //! `pt`, `pc`) keep their physical size (72 pt per inch): `font-size="12pt"` is 12 pt. A root //! `width`/`height` in absolute units keeps its physical size, so a 210 mm SVG opens on a 210 mm -//! artboard, its user units being CSS pixels of it (96 per inch). The document's units follow the -//! unit of the root `width` (pixels when it has none). +//! artboard, its user units being CSS pixels of it (96 per inch), the other side's too when only +//! one is absolute. The document's units follow the unit of the root `width` (pixels when it has +//! none). //! * `` imports as a luminance opacity mask; a mask we exported keeps its options and art //! (its `data-vectorcraft-mask="noclip invert"` lists the options that differ from clipping and //! not inverted). Nested clip paths (``) are clip groups in clip groups. diff --git a/crates/svg/tests/effects_export.rs b/crates/svg/tests/effects_export.rs index 5e6f433ed..ea63c7fc4 100644 --- a/crates/svg/tests/effects_export.rs +++ b/crates/svg/tests/effects_export.rs @@ -21,6 +21,23 @@ fn fx(id: &str) -> Effect { Effect { id: id.into(), params: json!({}), visible: true } } +#[test] +fn revolve_exports_shaded_vector_faces_and_keeps_the_source_live() { + let bp = vectorcraft_geom::BezPath::from_svg("M100 30 L130 50 L130 140 L100 160").unwrap(); + let mut n = Node::path( + NodeId(0), + vectorcraft_geom::PathData::from_bezpath(&bp), + Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0), + ); + n.appearance.effects.push(fx("threeD.revolve")); + let d = doc_with(n); + let before = d.clone(); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.matches(" 50, "Revolve should export vector surfaces"); + assert!(!svg.contains(""##); + let d = import(&svg("")).unwrap(); + assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect); + assert!(close_rect(rect(&d), Rect::new(0.0, 0.0, 24.0, 24.0), 1e-6), "{size}: {:?}", rect(&d)); + // With a viewBox: fitted uniformly (xMidYMid meet), unless preserveAspectRatio is none. + let d = import(&svg(r#"viewBox="0 0 32 32""#)).unwrap(); + assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect); + assert!(close_rect(rect(&d), Rect::new(12.0, 0.0, 84.0, 72.0), 1e-6), "{size} viewBox: {:?}", rect(&d)); + let d = import(&svg(r#"viewBox="0 0 32 32" preserveAspectRatio="none""#)).unwrap(); + assert!(close_rect(rect(&d), board, 1e-6), "{size} none: {:?}", rect(&d)); + } +} + #[test] fn full_document_roundtrip_is_stable() { // export → import → export produces identical SVG. diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index c6587ab27..94f4dee5f 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -105,6 +105,20 @@ fn rich_text_writes_spacing_shift_and_scale() { assert_eq!(attr(tags[1], "y"), attr(tags[0], "y")); } +/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own. +#[test] +fn proportional_metrics_write_palt() { + let proportional = CharStyle { proportional_metrics: true, ..style(12.0) }; + let both = CharStyle { proportional_metrics: true, features: vec!["dlig".into()], ..style(12.0) }; + let d = area_doc(vec![run("雅楽 ", style(12.0)), run("「あ」 ", proportional), run("ab", both)], 280.0, Justify::Left); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert_eq!(tags.len(), 3, "{svg}"); + assert!(!tags[0].contains("palt"), "{}", tags[0]); + assert!(tags[1].contains("font-feature-settings:"palt" 1"), "{}", tags[1]); + assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]); +} + #[test] fn fewer_tspans_writes_one_per_line() { let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) }; diff --git a/crates/text/build.rs b/crates/text/build.rs index 73c952faf..25544a3a6 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -51,6 +51,12 @@ fn main() { } let src = format!("pub static CRAFT_FONTS: &[CraftFont] = &[\n{fonts}];\npub static WEB_FONTS: &[WebFont] = &[\n{web}];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); + // Unchanged output keeps its mtime. Rewritten just before this crate compiles, it can read as + // newer than that compile where cargo's recorded times lose their sub-second part (ecryptfs + // home directories), and the next build would compile this crate and everything above it again. + if std::fs::read_to_string(&out).is_ok_and(|old| old == src) { + return; + } if let Err(e) = std::fs::write(&out, src) { println!("cargo::error=writing {}: {e}", out.display()); } diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 1cf826302..88bf919d5 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -160,6 +160,23 @@ impl OtFeatures { // and digits itself (they would lie upside down, and tate-chu-yoko break). v.push(f(b"vert", true)); } + if self.proportional(st) { + v.push(f(b"palt", true)); + } + v + } + + /// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in + /// horizontal type; vertical type (`vpal`) needs vertical shaping, so it sets as before. + pub(crate) fn proportional(&self, st: &CharStyle) -> bool { + st.proportional_metrics && !self.vertical + } + + /// The harfrust features for text in style `st` with full-width glyphs on their full widths: + /// [`Self::resolve`] without `palt`. + pub(crate) fn resolve_fixed_width(&self, st: &CharStyle) -> Vec { + let mut v = self.resolve(st); + v.retain(|x| x.tag != Tag::new(b"palt")); v } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index bba0a7c1b..338820588 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -200,15 +200,21 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay } /// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which -/// it fits, searched by bisection over the first run's size in steps of 0.1 pt (so its scaled size +/// it fits, searched by bisection over the first run's document size in steps of 0.1 pt (so its scaled size /// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there, /// still overflowing. None (keep the full-size layout) when the text has no usable size. fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option { - let size = t.runs.first().map(|r| r.style.size).filter(|s| s.is_finite() && *s >= 0.1)?; + // Exact size edits after object scaling may store a local size below 0.1. Fit in the + // same document points as the Character controls, independent of that representation. + let points = t.style_scale()?.points; + let size = t.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?; if !(min.is_finite() && min < 1.0) { return None; } let tenths = size * 10.0; + if !tenths.is_finite() { + return None; + } // Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow // unless it is below the full size; `lo` is the smallest allowed. let mut lo = (tenths * min).ceil().max(1.0); @@ -234,7 +240,7 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> } // Invariant: everything at `hi` or above overflows; `lo` fits or is the floor. while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES { - let mid = ((lo + hi) * 0.5).floor(); + let mid = (lo + (hi - lo) * 0.5).floor(); let l = at(mid); passes += 1; if l.overflow { @@ -381,7 +387,8 @@ fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) { /// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its /// advance an em, give or take a tenth), the space that mojikumi can take off. fn punct_half(g: &SGlyph, kind: Punct) -> Option { - if punct(g.ch)? != kind || g.tcy.is_some() { + // A glyph Proportional Metrics re-spaced has no empty half left to take off (#966). + if punct(g.ch)? != kind || g.tcy.is_some() || g.proportional { return None; } let em = g.face.units_per_em(); @@ -1170,6 +1177,7 @@ mod wrapping_tests { tcy: None, inline: None, lead: 0.0, + proportional: false, level: unicode_bidi::Level::ltr(), }) .collect() diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index f82f447cd..328fcac17 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -49,6 +49,9 @@ pub(crate) struct SGlyph { /// Japanese composition: the space taken off before the glyph (an opening bracket after /// another, see [`crate::layout`]); the glyph is drawn that much earlier on the line. pub lead: f64, + /// Proportional Metrics changed the glyph's advance (`palt`): it has its proportional width, + /// and Japanese composition takes no more space off it (#966). + pub proportional: bool, /// Resolved Unicode bidi embedding level (logical source order). pub level: Level, /// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) standing in for a glyph. @@ -117,6 +120,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph { h.len = 0; h.ch = '-'; h.inline = None; + h.proportional = false; h } @@ -422,6 +426,7 @@ fn inline_glyph(face: &Arc, st: &CharStyle, art: &InlineArt, run: usiz ch: text.get(range.clone()).and_then(|s| s.chars().next()).unwrap_or(INLINE_CHAR), tcy: None, lead: 0.0, + proportional: false, // U+FFFC is a bidi neutral (ON): its level, resolved from its neighbours, places it in // RTL text like any other glyph once the line is reordered. level, @@ -470,34 +475,48 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' '); let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff + // Proportional Metrics: which glyphs `palt` gave another advance than their full width. + let mut proportional = vec![]; let shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); - let mut buf = UnicodeBuffer::new(); - for (i, c) in text_seg.char_indices() { - let cl = (range.start + i) as u32; - if upper { - for u in c.to_uppercase() { - buf.add(u, cl); + let shape = |feats: &[Feature]| { + let mut buf = UnicodeBuffer::new(); + for (i, c) in text_seg.char_indices() { + let cl = (range.start + i) as u32; + if upper { + for u in c.to_uppercase() { + buf.add(u, cl); + } + } else { + buf.add(c, cl); } - } else { - buf.add(c, cl); } - } - buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight }); - buf.guess_segment_properties(); - // Harfrust keeps this Unicode context for joining/positional shaping, but does not emit - // glyphs for it. This retains context at real style/font segmentation boundaries without - // shaping across those boundaries or claiming cross-style ligatures/mark attachment. - if let Some(pre) = text.get(context.start..range.start) { - buf.set_pre_context(pre); - } - if let Some(post) = text.get(range.end..context.end) { - buf.set_post_context(post); - } - let feats: Vec = feats.resolve(st); - let gb = shaper.shape(buf, ShapeOptions::new().features(&feats)); - for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { - raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset)); + buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight }); + buf.guess_segment_properties(); + // Harfrust keeps this Unicode context for joining/positional shaping, but does not emit + // glyphs for it. This retains context at real style/font segmentation boundaries without + // shaping across those boundaries or claiming cross-style ligatures/mark attachment. + if let Some(pre) = text.get(context.start..range.start) { + buf.set_pre_context(pre); + } + if let Some(post) = text.get(range.end..context.end) { + buf.set_post_context(post); + } + let gb = shaper.shape(buf, ShapeOptions::new().features(feats)); + gb.glyph_infos() + .iter() + .zip(gb.glyph_positions()) + .map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset)) + .collect::>() + }; + raw = shape(&feats.resolve(st)); + if feats.proportional(st) { + // `palt` is positioning only: the same glyphs set on their full widths tell which + // advances it changed (kerning and the rest change both alike). + let full = shape(&feats.resolve_fixed_width(st)); + if full.len() == raw.len() && full.iter().zip(&raw).all(|(a, b)| a.0 == b.0) { + proportional = full.iter().zip(&raw).map(|(a, b)| a.2 != b.2).collect(); + } } }); if shaped.is_none() { @@ -553,6 +572,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe ch, tcy: None, lead: 0.0, + proportional: proportional.get(gi).copied().unwrap_or(false), level: *level, inline: None, }); diff --git a/crates/text/src/tests_fit.rs b/crates/text/src/tests_fit.rs index 195d6ccb5..f0ede1003 100644 --- a/crates/text/src/tests_fit.rs +++ b/crates/text/src/tests_fit.rs @@ -54,6 +54,19 @@ fn shrink_text_makes_overflowing_text_fit() { assert_eq!(layout(db(), &t).fit_scale, l.fit_scale); } +#[test] +fn scaled_area_shrink_accepts_small_local_sizes_and_uses_document_tenths() { + // A 14 pt story represented in a scaled local coordinate system. Its local size is + // below 0.1, as happens when setting an exact size after enlarging a text group. + let mut t = area(COPY, 0.014, Rect::new(0.0, 0.0, 0.2, 0.09), shrink(30.0)); + t.xf = Affine::scale(1000.0); + assert!(at_size(&t, 0.014).overflow); + let l = layout(db(), &t); + assert!(l.fit_scale < 1.0 && l.fit_scale >= 0.3, "{}", l.fit_scale); + let document_size = 14.0 * l.fit_scale; + assert!((document_size * 10.0 - (document_size * 10.0).round()).abs() < 1e-6); +} + #[test] fn shrink_text_respects_its_minimum() { let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(90.0)); diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index 4897e53be..5bf85d8a0 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -749,3 +749,48 @@ fn layout_10k_area_text_is_fast() { assert!(per < budget, "{justify:?} {composer:?}: {per} ms"); } } + +/// Proportional Metrics (#966): in horizontal type full-width glyphs take the font's proportional +/// widths (`palt`), and Japanese composition takes nothing more off the punctuation `palt` +/// re-spaced (the flush bracket at a paragraph's start, consecutive punctuation, half width at a +/// line's end); punctuation it leaves full width is trimmed as before. Vertical type sets as +/// before. Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not +/// 〘〙 (skipped without it). +#[test] +fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() { + use vectorcraft_doc::Mojikumi; + if db().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let st = |on: bool| CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), proportional_metrics: on, ..CharStyle::default() }; + // Right-aligned in a frame ten ems wide, so a line's end trim moves the line. + let lay = |text: &str, on: bool, m: Mojikumi, vertical: bool| { + let mut t = area(text, st(on), Rect::new(0.0, 0.0, 200.0, 200.0), Justify::Right); + t.vertical = vertical; + t.para.mojikumi = m; + layout(db(), &t) + }; + let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::>(); + let x0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().x0).collect::>(); + let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01); + let off = adv(&lay("「あ漢」", false, Mojikumi::None, false)); + let on = adv(&lay("「あ漢」", true, Mojikumi::None, false)); + assert!(off.iter().all(|a| (a - 20.0).abs() < 0.01), "{off:?}"); + assert!(on[0] < 15.0 && on[1] < 19.0 && on[3] < 15.0, "kana and brackets narrower: {on:?}"); + assert!((on[2] - 20.0).abs() < 0.01, "the kanji keeps its em: {on:?}"); + // Re-spaced punctuation: Line-end Punctuation Half Width changes nothing, at the paragraph's + // start (「), between punctuation (」、) or at the line's end (」). + for text in ["「あ」、漢", "漢「あ」"] { + let (half, none) = (lay(text, true, Mojikumi::LineEndHalf, false), lay(text, true, Mojikumi::None, false)); + assert!(near(&adv(&half), &adv(&none)), "{text}: {:?} {:?}", adv(&half), adv(&none)); + assert!(near(&x0(&half), &x0(&none)), "{text}: {:?} {:?}", x0(&half), x0(&none)); + } + // Punctuation `palt` leaves full width: flush at the paragraph's start, half width at the end. + let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf, false), lay("〘あ〙", true, Mojikumi::None, false)); + assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half)); + let moved = x0(&half)[1] - x0(&none)[1]; + assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}"); + // Vertical type sets as before. + assert!(near(&adv(&lay("「あ漢」", true, Mojikumi::None, true)), &adv(&lay("「あ漢」", false, Mojikumi::None, true)))); +} diff --git a/crates/three-d/Cargo.toml b/crates/three-d/Cargo.toml new file mode 100644 index 000000000..c7fe816b5 --- /dev/null +++ b/crates/three-d/Cargo.toml @@ -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 } diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs new file mode 100644 index 000000000..535232889 --- /dev/null +++ b/crates/three-d/src/lib.rs @@ -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, + pub depth: f64, + pub brightness: f64, +} + +impl Face { + pub fn path(&self) -> PathData { + let mut bp = BezPath::new(); + if let Some(first) = self.points.first() { + bp.move_to(*first); + for p in self.points.iter().skip(1) { + bp.line_to(*p); + } + bp.close_path(); + } + PathData::from_bezpath(&bp) + } +} + +/// Evaluate a profile in document space. All subpaths share one axis from `bounds`. +/// Invalid or excessively complex inputs return an error, never a partial surface. +pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, String> { + let o = options.sanitized(); + if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { + return Err("Revolve requires finite profile coordinates within 100 million points".into()); + } + let axis = match o.edge { + Edge::Left => bounds.x0 - o.offset, + Edge::Right => bounds.x1 + o.offset, + }; + let center_y = (bounds.y0 + bounds.y1) * 0.5; + let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs()); + let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0); + let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1); + let sweep = o.angle.to_radians(); + let [lx, ly, lz] = o.light_direction(); + let light = V3(lx, ly, lz); + let mut faces = Vec::new(); + let mut count = 0; + for sp in &path.subpaths { + let mut profile = Vec::new(); + if let Some(first) = sp.anchors.first() { + profile.push(first.p); + count += 1; + if count > MAX_PROFILE_POINTS { + return Err("Revolve profile is too complex".into()); + } + } + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + // Bound work before sampling any data. Curves get sixteen samples, straight edges one. + if segments > MAX_PROFILE_POINTS { + return Err("Revolve profile is too complex".into()); + } + for i in 0..segments { + let curve = sp.segment(i); + let samples = if sp.segment_is_line(i) { 1 } else { 16 }; + for k in 1..=samples { + profile.push(curve.eval(k as f64 / samples as f64)); + count += 1; + if count > MAX_PROFILE_POINTS { + return Err("Revolve profile is too complex".into()); + } + } + } + for pair in profile.windows(2) { + let [a, b] = pair else { continue }; + if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { + return Err("Revolve profile contains invalid coordinates".into()); + } + for j in 0..steps { + let vertex = |p: Point, step: usize| { + let (s, c) = (sweep * step as f64 / steps as f64).sin_cos(); + o.rotate(V3((p.x - axis) * c, p.y - center_y, (p.x - axis) * s)) + }; + let vertices = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)]; + // The diagonal gives a nonzero normal even when one profile endpoint is on-axis. + let mut normal = vertices[1].sub(vertices[0]).cross(vertices[2].sub(vertices[0])).unit(); + if normal.dot(normal) < 0.5 { + normal = vertices[2].sub(vertices[0]).cross(vertices[3].sub(vertices[0])).unit(); + } + if normal.dot(normal) < 0.5 { + continue; + } + // Profiles may run up or down. Shade the visible side of this two-sided surface. + if normal.2 < 0.0 { + normal = V3(-normal.0, -normal.1, -normal.2); + } + let brightness = if o.shade { (o.ambient + o.light_intensity * normal.dot(light).max(0.0)).clamp(0.0, 1.0) } else { 1.0 }; + let points: Vec<_> = vertices + .iter() + .map(|v| { + let factor = distance / (distance - v.2 * o.perspective); + Point::new(axis + v.0 * factor, center_y + v.1 * factor) + }) + .collect(); + if points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return Err("Revolve projection is not finite".into()); + } + let area = points + .iter() + .zip(points.iter().cycle().skip(1)) + .take(4) + .map(|(a, b)| (a.x - axis) * (b.y - center_y) - (b.x - axis) * (a.y - center_y)) + .sum::(); + if area.abs() < 1e-10 { + continue; + } + faces.push(Face { points, depth: vertices.iter().map(|v| v.2).sum::() / 4.0, brightness }); + if faces.len() > MAX_FACES { + return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into()); + } + } + } + } + faces.sort_by(|a, b| a.depth.total_cmp(&b.depth)); + Ok(faces) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() { + for azimuth in [-170.0f64, -45.0, 0.0, 83.0, 167.0] { + for elevation in [-85.0f64, -30.0, 0.0, 45.0, 90.0] { + let o = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..Default::default() }; + let direction = o.light_direction(); + for ([center, u, v], angle) in o.light_orbits().into_iter().zip([azimuth, elevation]) { + let (s, c) = angle.to_radians().sin_cos(); + for i in 0..3 { + assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12); + } + } + let [a, e] = o.light_at_on_side(Point::new(direction[0], direction[1])); + assert!((a - azimuth).abs() < 1e-9 && (e - elevation).abs() < 1e-9, "{azimuth}, {elevation} -> {a}, {e}"); + } + } + let o = Revolve { light_azimuth: 135.0, ..Default::default() }; + let [azimuth, elevation] = o.light_at_on_side(Point::new(0.3, -0.4)); + let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction(); + assert!((x - 0.3).abs() < 1e-12 && (y + 0.4).abs() < 1e-12 && z < 0.0); + let [a, e] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY)); + assert!(a.is_finite() && e.is_finite()); + } + + #[test] + fn ring_axes_match_euler_parameter_rotation_and_origin_stays_fixed() { + let o = Revolve { rotation_x: 27.0, rotation_y: -39.0, rotation_z: 53.0, ..Default::default() }; + let seed = V3(0.3, -0.5, 0.7); + let before = o.rotate(seed); + let planes = o.rotation_planes(); + for (axis, [u, v]) in planes.iter().enumerate() { + let normal = V3(u[0], u[1], u[2]).cross(V3(v[0], v[1], v[2])); + assert!((normal.dot(normal) - 1.0).abs() < 1e-12); + let mut changed = o; + match axis { + 0 => changed.rotation_x += 0.0001, + 1 => changed.rotation_y += 0.0001, + _ => changed.rotation_z += 0.0001, + } + let actual = changed.rotate(seed).sub(before); + let expected = normal.cross(before); + let step = 0.0001f64.to_radians(); + for (a, b) in [(actual.0 / step, expected.0), (actual.1 / step, expected.1), (actual.2 / step, expected.2)] { + assert!((a - b).abs() < 1e-5); + } + } + assert_eq!(Revolve { edge: Edge::Right, offset: 12.0, ..o }.rotation_origin(Rect::new(20.0, 30.0, 80.0, 100.0)), Point::new(92.0, 65.0)); + } + #[test] + fn guides_match_projection_and_dragged_light_matches_shading_direction() { + let o = Revolve { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, offset: 10.0, ..Default::default() }; + assert_eq!(o.view_axes(), [Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]); + assert_eq!(o.projected_axis(Rect::new(20.0, 30.0, 80.0, 100.0)), [Point::new(10.0, 30.0), Point::new(10.0, 100.0)]); + let [azimuth, elevation] = Revolve::light_at(Point::new(0.3, -0.4)); + let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction(); + assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0); + let [azimuth, elevation] = Revolve::light_at(Point::new(2.0, -2.0)); + assert!(azimuth.is_finite() && elevation.is_finite() && elevation.abs() <= 90.0); + } + fn cylinder() -> (PathData, Rect) { + let mut bp = BezPath::new(); + bp.move_to((40.0, 10.0)); + bp.line_to((40.0, 90.0)); + (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0)) + } + #[test] + fn cylinder_has_expected_silhouette_depth_order_and_shading() { + let (p, b) = cylinder(); + let faces = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + let points: Vec<_> = faces.iter().flat_map(|f| &f.points).collect(); + assert!((points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min) + 40.0).abs() < 1e-6); + assert!((points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max) - 40.0).abs() < 1e-6); + assert!(faces.windows(2).all(|w| w[0].depth <= w[1].depth)); + assert!(faces.iter().any(|f| f.brightness > 0.8) && faces.iter().any(|f| f.brightness < 0.4)); + } + #[test] + fn edge_offset_sweep_and_rotation_change_the_surface() { + let (p, b) = cylinder(); + let base = revolve(&p, b, Revolve::default()).unwrap(); + for o in [ + Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() }, + Revolve { angle: 120.0, ..Default::default() }, + Revolve { rotation_z: 60.0, perspective: 0.8, ..Default::default() }, + ] { + assert_ne!(base, revolve(&p, b, o).unwrap()); + } + assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty()); + } + #[test] + fn malformed_and_degenerate_inputs_are_bounded() { + let (p, b) = cylinder(); + let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap(); + assert!(f.len() <= 128); + assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err()); + assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty()); + } +} diff --git a/crates/three-d/src/visibility.rs b/crates/three-d/src/visibility.rs new file mode 100644 index 000000000..ce2bfa744 --- /dev/null +++ b/crates/three-d/src/visibility.rs @@ -0,0 +1,325 @@ +//! Vector visibility in the evaluator's paint order. Convex projected faces are cut by the +//! nearer faces covering them, irrespective of their winding or which side faces the camera. +//! This preserves interiors visible through open ends and partial sweeps. + +use vectorcraft_geom::{PathData, Point, Rect, SubPath}; + +use crate::{Face, MAX_FACES}; + +const GRID: usize = 32; +const MAX_GRID_REFS: usize = 2_000_000; +const MAX_WORK: usize = 16_000_000; +const MAX_FRAGMENTS: usize = 256; +const MAX_OUTPUT_POINTS: usize = 262_144; + +/// The visible portions of one source face, with its original shading and paint order. +#[derive(Clone, Debug, PartialEq)] +pub struct VisibleFace { + pub path: PathData, + pub brightness: f64, + pub depth: f64, +} + +struct Polygon { + points: Vec, + bounds: Rect, +} + +fn bounds(points: &[Point]) -> Rect { + let Some(first) = points.first() else { return Rect::ZERO }; + points.iter().fold(Rect::from_points(*first, *first), |b, p| b.union_pt(*p)) +} + +fn area(points: &[Point]) -> f64 { + let Some(origin) = points.first() else { return 0.0 }; + points.iter().zip(points.iter().cycle().skip(1)).take(points.len()).map(|(a, b)| (*a - *origin).cross(*b - *origin)).sum::() / 2.0 +} + +fn clean(mut points: Vec, epsilon: f64) -> Vec { + points.dedup_by(|a, b| a.distance(*b) <= epsilon); + if points.len() > 1 && points.first().zip(points.last()).is_some_and(|(a, b)| a.distance(*b) <= epsilon) { + points.pop(); + } + if points.len() < 3 || area(&points).abs() <= epsilon * epsilon { + return vec![]; + } + if area(&points) < 0.0 { + points.reverse(); + } + points +} + +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 +} + +/// Split a convex polygon at an occluder's edge. Boundary vertices belong to both halves, +/// so independently clipped neighbours share precisely the same intersection positions. +fn split(points: &[Point], a: Point, b: Point, epsilon: f64) -> (Vec, Vec) { + let edge = b - a; + let mut inside = vec![]; + let mut outside = vec![]; + for (p, q) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) { + let dp = edge.cross(*p - a); + let dq = edge.cross(*q - a); + if dp >= 0.0 { + inside.push(*p); + } + if dp <= 0.0 { + outside.push(*p); + } + if (dp > 0.0 && dq < 0.0) || (dp < 0.0 && dq > 0.0) { + let intersection = p.lerp(*q, dp / (dp - dq)); + inside.push(intersection); + outside.push(intersection); + } + } + (clean(inside, epsilon), clean(outside, epsilon)) +} + +fn subtract(points: Vec, cover: &Polygon, epsilon: f64, work: &mut usize) -> Result>, String> { + if !overlaps(bounds(&points), cover.bounds) { + return Ok(vec![points]); + } + let mut remaining = points; + let mut visible = vec![]; + for (a, b) in cover.points.iter().zip(cover.points.iter().cycle().skip(1)).take(cover.points.len()) { + *work += remaining.len(); + if *work > MAX_WORK { + return Err("Revolve visibility exceeds its geometry work limit".into()); + } + let (inside, outside) = split(&remaining, *a, *b, epsilon); + if !outside.is_empty() { + visible.push(outside); + } + remaining = inside; + if remaining.is_empty() { + break; + } + } + Ok(visible) +} + +struct Index { + cells: Vec>, + bounds: Rect, + refs: usize, +} + +impl Index { + fn tiles(&self, b: Rect) -> impl Iterator { + let cell = |v: f64, lo: f64, size: f64| (((v - lo) / size * GRID as f64).floor() as usize).min(GRID - 1); + let x0 = cell(b.x0, self.bounds.x0, self.bounds.width()); + let x1 = cell(b.x1, self.bounds.x0, self.bounds.width()); + let y0 = cell(b.y0, self.bounds.y0, self.bounds.height()); + let y1 = cell(b.y1, self.bounds.y0, self.bounds.height()); + (y0..=y1).flat_map(move |y| (x0..=x1).map(move |x| y * GRID + x)) + } + + fn insert(&mut self, b: Rect, face: usize) -> Result<(), String> { + let tiles: Vec<_> = self.tiles(b).collect(); + self.refs += tiles.len(); + if self.refs > MAX_GRID_REFS { + return Err("Revolve visibility exceeds its spatial index limit".into()); + } + for tile in tiles { + if let Some(cell) = self.cells.get_mut(tile) { + cell.push(face); + } + } + Ok(()) + } +} + +/// Remove covered portions of opaque faces in back-to-front paint order. This follows the +/// live evaluator's painter visibility, including its approximation for intersecting surfaces. +/// Resource limits return an error; callers can retain the original complete surface safely. +pub fn visible_faces(faces: &[Face]) -> Result, String> { + if faces.len() > MAX_FACES { + return Err("Too many Revolve faces for visibility evaluation".into()); + } + if faces.iter().any(|f| { + f.points.len() > 4 + || !f.brightness.is_finite() + || !f.depth.is_finite() + || f.points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8) + }) { + return Err("Revolve visibility requires finite projected convex faces".into()); + } + let all = faces.iter().flat_map(|f| &f.points).copied().collect::>(); + let total = bounds(&all); + if total.width() <= 0.0 || total.height() <= 0.0 { + return Ok(vec![]); + } + let epsilon = (total.width().max(total.height()) * 1e-10).clamp(1e-8, 1e-3); + let polygons: Vec<_> = faces + .iter() + .map(|f| { + let points = clean(f.points.clone(), epsilon); + Polygon { bounds: bounds(&points), points } + }) + .collect(); + // Generated quads are convex. Validate the public helper rather than accepting concave + // callers and silently deleting geometry they can see. + for p in &polygons { + if p.points.iter().enumerate().any(|(i, a)| { + let Some(b) = p.points.get((i + 1) % p.points.len()) else { return false }; + let Some(c) = p.points.get((i + 2) % p.points.len()) else { return false }; + (*b - *a).cross(*c - *b) < -epsilon * b.distance(*a).max(1.0) + }) { + return Err("Revolve visibility requires convex projected faces".into()); + } + } + let mut index = Index { cells: vec![vec![]; GRID * GRID], bounds: total, refs: 0 }; + let mut seen = vec![usize::MAX; faces.len()]; + let (mut out, mut work, mut output_points) = (vec![], 0, 0); + for (source, (face, polygon)) in faces.iter().zip(&polygons).enumerate().rev() { + if polygon.points.is_empty() { + continue; + } + let mut fragments = vec![polygon.points.clone()]; + 'covers: for tile in index.tiles(polygon.bounds) { + for &candidate in index.cells.get(tile).into_iter().flatten() { + work += 1; + if work > MAX_WORK { + return Err("Revolve visibility exceeds its geometry work limit".into()); + } + if let Some(mark) = seen.get_mut(candidate) + && *mark != source + { + *mark = source; + if let Some(cover) = polygons.get(candidate) + && overlaps(polygon.bounds, cover.bounds) + { + let mut remaining = vec![]; + for fragment in fragments { + remaining.extend(subtract(fragment, cover, epsilon, &mut work)?); + if remaining.len() > MAX_FRAGMENTS { + return Err("Revolve visibility creates too many fragments".into()); + } + } + fragments = remaining; + if fragments.is_empty() { + break 'covers; + } + } + } + } + } + if fragments.is_empty() { + continue; + } + output_points += fragments.iter().map(Vec::len).sum::(); + if output_points > MAX_OUTPUT_POINTS { + return Err("Revolve visibility creates too many vector anchors".into()); + } + let path = PathData::new(fragments.into_iter().map(|p| SubPath::polyline(&p, true)).collect()); + out.push(VisibleFace { path, brightness: face.brightness, depth: face.depth }); + // A completely covered face need not occupy the grid. Its coverage already belongs + // to nearer faces; this keeps repeated overlapping surfaces bounded in practice. + index.insert(polygon.bounds, source)?; + } + out.reverse(); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Edge, Revolve, revolve}; + use vectorcraft_geom::{BezPath, Shape}; + + fn face(rect: Rect, brightness: f64) -> Face { + Face { + points: vec![Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)], + depth: brightness, + brightness, + } + } + + #[test] + fn covered_faces_disappear_and_partial_faces_are_trimmed_in_paint_order() { + let faces = + vec![face(Rect::new(2.0, 2.0, 8.0, 8.0), 0.1), face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.2), face(Rect::new(5.0, 0.0, 15.0, 10.0), 0.8)]; + let visible = visible_faces(&faces).unwrap(); + assert_eq!(visible.len(), 2); + assert_eq!(visible[0].brightness, 0.2); + assert_eq!(visible[0].path.bounds(), Some(Rect::new(0.0, 0.0, 5.0, 10.0))); + assert!((visible[0].path.to_bezpath().area() - 50.0).abs() < 1e-8); + assert!((visible[1].path.to_bezpath().area() - 100.0).abs() < 1e-8); + // Winding and equal depth do not determine visibility; actual paint order does. + let mut flipped = faces.clone(); + for f in &mut flipped { + f.points.reverse(); + f.depth = 0.0; + } + let other = visible_faces(&flipped).unwrap(); + assert_eq!(visible.iter().map(|f| f.brightness).collect::>(), other.iter().map(|f| f.brightness).collect::>()); + assert_eq!(visible[0].path.bounds(), other[0].path.bounds()); + } + + #[test] + fn an_interior_surface_remains_visible_through_an_opening() { + let faces = vec![ + face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.1), + face(Rect::new(0.0, 0.0, 10.0, 3.0), 0.2), + face(Rect::new(0.0, 7.0, 10.0, 10.0), 0.3), + face(Rect::new(0.0, 3.0, 3.0, 7.0), 0.4), + face(Rect::new(7.0, 3.0, 10.0, 7.0), 0.5), + ]; + let visible = visible_faces(&faces).unwrap(); + assert_eq!(visible[0].brightness, 0.1); + assert_eq!(visible[0].path.bounds(), Some(Rect::new(3.0, 3.0, 7.0, 7.0))); + assert_eq!(visible[0].path.to_bezpath().winding(Point::new(5.0, 5.0)), 1); + assert_eq!(visible[0].path.to_bezpath().winding(Point::new(1.0, 1.0)), 0); + } + + #[test] + fn revolved_open_profiles_match_visible_paint_for_both_axis_sides_and_perspective() { + let mut bp = BezPath::new(); + bp.move_to((40.0, 10.0)); + bp.curve_to((75.0, 20.0), (75.0, 80.0), (40.0, 90.0)); + let path = PathData::from_bezpath(&bp); + for edge in [Edge::Left, Edge::Right] { + for angle in [155.0, 360.0] { + let options = Revolve { + edge, + angle, + offset: 8.0, + segments: 32, + rotation_x: -32.0, + rotation_y: 24.0, + rotation_z: 19.0, + perspective: 0.7, + ..Default::default() + }; + let faces = revolve(&path, path.bounds().unwrap(), options).unwrap(); + let visible = visible_faces(&faces).unwrap(); + assert!(visible.len() <= faces.len()); + let all: Vec<_> = faces.iter().map(|f| (f.path().to_bezpath(), f.brightness)).collect(); + let cut: Vec<_> = visible.iter().map(|f| (f.path.to_bezpath(), f.brightness)).collect(); + let b = bounds(&faces.iter().flat_map(|f| &f.points).copied().collect::>()); + for x in 0..48 { + for y in 0..48 { + let p = Point::new(b.x0 + b.width() * (x as f64 + 0.337) / 48.0, b.y0 + b.height() * (y as f64 + 0.413) / 48.0); + let expected = all.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, value)| value); + let actual = cut.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, value)| value); + assert_eq!(actual, expected, "{edge:?}, {angle}, {p:?}"); + assert!(cut.iter().filter(|(bp, _)| bp.winding(p) != 0).count() <= 1, "expanded surfaces do not cover hidden regions"); + } + } + } + } + } + + #[test] + fn visibility_is_bounded_for_repeated_faces_and_rejects_invalid_input() { + let faces = vec![face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.5); MAX_FACES]; + assert_eq!(visible_faces(&faces).unwrap().len(), 1); + assert!(visible_faces(&vec![faces[0].clone(); MAX_FACES + 1]).is_err()); + let mut bad = faces[0].clone(); + bad.points[0].x = f64::NAN; + assert!(visible_faces(&[bad]).is_err()); + assert!(visible_faces(&[]).unwrap().is_empty()); + } +} diff --git a/crates/tools/src/cropimage.rs b/crates/tools/src/cropimage.rs index 467182a78..c07116390 100644 --- a/crates/tools/src/cropimage.rs +++ b/crates/tools/src/cropimage.rs @@ -232,9 +232,15 @@ impl Tool for CropImageTool { if key != "rect" { return; } - let n: Vec = value.as_array().map(|v| v.iter().filter_map(Value::as_f64).filter(|n| n.is_finite()).collect()).unwrap_or_default(); - self.rect = match n.as_slice() { - [x, y, w, h] => Some((None, Rect::new(*x, *y, x + w, y + h))), + // Invalid components must discard the whole box, not silently shift the + // coordinates by dropping a non-numeric entry. + let numbers = value + .as_array() + .filter(|a| a.len() == 4) + .and_then(|a| a.iter().map(Value::as_f64).collect::>>()) + .filter(|a| a.iter().all(|n| n.is_finite())); + self.rect = match numbers.as_deref() { + Some([x, y, w, h]) => Some((None, Rect::new(*x, *y, x + w, y + h))), _ => None, }; } @@ -336,6 +342,17 @@ mod tests { assert!(t.overlays(&cx).is_empty() && !t.claims_key(&cx, ToolKey::Enter)); } + #[test] + fn crop_box_option_requires_exactly_four_finite_numeric_values() { + let mut tool = CropImageTool::default(); + for invalid in [json!([10, "ignored", 20, 30, 40]), json!([10, 20, 30, null, 40]), json!([10, 20, 30, 40, 50]), json!("not a rectangle")] { + tool.set_option("rect", &invalid); + assert!(tool.options().get("rect").is_none(), "invalid box should reset crop: {invalid}"); + } + tool.set_option("rect", &json!([10, 20, 30, 40])); + assert_eq!(tool.options()["rect"], json!([10.0, 20.0, 30.0, 40.0])); + } + #[test] fn the_tool_is_made_by_its_id() { assert_eq!(crate::create(ID).id(), ID); diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index a8c490b95..739423b27 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -15,7 +15,8 @@ //! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]). //! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves //! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes -//! and mesh envelopes and their handles ([`MeshEdit`]). Dragging a corner or an edge of area +//! and mesh envelopes and their handles ([`MeshEdit`]; a solid fill colour recolours a gradient +//! mesh's point last clicked). Dragging a corner or an edge of area //! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size. //! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]). //! @@ -291,6 +292,9 @@ impl Tool for DirectSelectionTool { fn busy(&self) -> bool { !matches!(self.state, State::Idle) || self.guide.busy() } + fn options(&self) -> Value { + self.mesh.options() + } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { if let Some(out) = self.guide.pointer(cx, ev) { return out; diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 519c47aef..065fa41bd 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -372,7 +372,13 @@ impl Targets { /// tools): they pull a point into line where they run. fn with_rulers(mut self, cx: &ToolContext) -> Self { if cx.guides { - self.rulers = cx.doc.guides.iter().map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }).collect(); + self.rulers = cx + .doc + .guides + .iter() + .filter(|g| cx.doc.guide_shown(g)) + .map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }) + .collect(); } self } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 9f62a366b..c15cce789 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -425,6 +425,11 @@ pub enum Cursor { ResizeNwSe, ResizeNeSw, Rotate, + /// The rotate cursor beside a bounding box, bowed toward the pointer's side of the box: off + /// its centre in direction `0..8` (0 straight up, then clockwise in 45° steps). Its arc wraps + /// the corner it is at, so at the top right it opens toward the bottom left. Make it with + /// [`Cursor::rotate_about`]. + RotateToward(u8), /// Over a Live Corners widget (drag to round the corners). CornerRadius, Pen, @@ -476,6 +481,18 @@ pub enum Cursor { } impl Cursor { + /// The rotate cursor for the pointer at `p` turning art about `center`: [`Cursor::RotateToward`] + /// the pointer's side of it, or the plain [`Cursor::Rotate`] on the centre itself. + pub fn rotate_about(center: Point, p: Point) -> Self { + let d = p - center; + if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { + return Cursor::Rotate; + } + // y points down the page: atan2(x, -y) is 0 straight up and grows clockwise. + let octant = (d.x.atan2(-d.y) / std::f64::consts::FRAC_PI_4).round() as i64; + Cursor::RotateToward(octant.rem_euclid(8) as u8) + } + /// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the /// Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot. pub fn precise(self) -> Self { @@ -485,6 +502,7 @@ impl Cursor { | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue + | Cursor::PenJoin | Cursor::PenConvert | Cursor::Eyedropper | Cursor::Slice diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 5958b5509..e5b747daf 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -8,7 +8,8 @@ //! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's //! new point takes the current fill colour unless Shift); drag a mesh point, or a handle of the //! point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); Alt-click a mesh point → -//! `object.mesh.deletePoint`. +//! `object.mesh.deletePoint`. Clicking a gradient mesh's point selects the mesh, and the next +//! solid fill colour recolours just that point. use serde_json::json; use vectorcraft_color::Paint; @@ -183,7 +184,14 @@ impl Tool for MeshTool { self.edit.unfocus(); return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": g.id.0, "index": g.index}))]; } - return self.edit.press(g); + // A gradient mesh's point is picked for the next fill colour, so its mesh is + // what gets selected. + let mut out = vec![]; + if cx.selection.objects != [g.id] && cx.doc.node(g.id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_))) { + out.push(Action::Exec("select.set".into(), json!({"ids": [g.id.0]}))); + } + out.extend(self.edit.press(g)); + return out; } self.edit.unfocus(); let Some((top, leaf)) = hit_top(cx, p) else { return vec![] }; @@ -236,6 +244,9 @@ impl Tool for MeshTool { None => Cursor::PenAdd, } } + fn options(&self) -> serde_json::Value { + self.edit.options() + } fn busy(&self) -> bool { self.edit.dragging() } diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs index 3a6a53feb..1af0998cf 100644 --- a/crates/tools/src/meshedit.rs +++ b/crates/tools/src/meshedit.rs @@ -1,10 +1,11 @@ //! Dragging mesh points and their handles on the canvas, shared by the Mesh tool and Direct //! Selection: gradient meshes and mesh envelopes alike (`object.mesh.movePoint`). Clicking a -//! point focuses it: its handles show and can be dragged too. +//! point focuses it: its handles show and can be dragged too, and on a gradient mesh a solid fill +//! colour recolours it (the tool's `meshPoint` option). use std::borrow::Cow; -use serde_json::json; +use serde_json::{Value, json}; use vectorcraft_doc::live::{GradientMesh, envelope_grid}; use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind}; use vectorcraft_geom::Point; @@ -91,6 +92,12 @@ impl MeshEdit { self.focus = None; } + /// `{meshPoint: {id, index}|null}`: the focused point, which a solid fill colour recolours + /// (`paint.setFill` on a gradient mesh). + pub fn options(&self) -> Value { + json!({"meshPoint": self.focus.map(|(id, index)| json!({"id": id.0, "index": index}))}) + } + /// The focused point's handles (lines and ends). pub fn overlays(&self, cx: &ToolContext) -> Vec { let Some((id, index)) = self.focus else { return vec![] }; diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index dccdeed13..d798e260f 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -5,7 +5,8 @@ //! selected guides (Alt copies them). A dragged guide snaps ([`GuideSnap`]): with Shift to the //! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards' //! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its -//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides) can't be picked. +//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden +//! or locked layer, can't be picked. use serde_json::json; use vectorcraft_geom::Point; @@ -24,7 +25,7 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option { .guides .iter() .enumerate() - .filter(|(_, g)| cx.doc.guide_passes(g, p, tol)) + .filter(|(_, g)| cx.doc.guide_editable(g) && cx.doc.guide_passes(g, p, tol)) .map(|(i, g)| (i, off(g.pos, g.vertical))) .filter(|(_, d)| *d <= tol) .min_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0))) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 4a3a4dca2..21934d78b 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -444,7 +444,7 @@ impl Tool for SelectionTool { fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { match self.state { - State::Rotating { .. } => return Cursor::Rotate, + State::Rotating { center, .. } => return Cursor::rotate_about(center, p), State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle), State::Moving { began: true, .. } => return Cursor::Arrow, State::Corner(_) => return Cursor::CornerRadius, @@ -468,7 +468,7 @@ impl Tool for SelectionTool { { match box_hit(cx, &bx, p) { Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle), - Some(BoxHit::Rotate) => return Cursor::Rotate, + Some(BoxHit::Rotate) => return Cursor::rotate_about(bx.center(), p), None => {} } } @@ -588,6 +588,24 @@ mod tests { assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 160.0, 150.0)).is_empty()); } + /// Just outside each corner of the box the rotate cursor bows toward that corner, its arc + /// opening toward the box: at the top right it opens to the bottom left. + #[test] + fn the_rotate_cursor_wraps_the_corner_it_is_at() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let t = SelectionTool::default(); + // The box runs from (100, 100) to (200, 200); 10 pt out from each corner, diagonally. + for ((x, y), octant) in [((93.0, 93.0), 7), ((207.0, 93.0), 1), ((207.0, 207.0), 3), ((93.0, 207.0), 5)] { + assert_eq!(t.cursor(&cx, Point::new(x, y), Mods::default()), Cursor::RotateToward(octant), "at ({x}, {y})"); + } + assert_eq!(Cursor::rotate_about(Point::new(1.0, 1.0), Point::new(1.0, 1.0)), Cursor::Rotate, "on the centre"); + assert_eq!(Cursor::rotate_about(Point::ORIGIN, Point::new(0.0, -5.0)), Cursor::RotateToward(0), "straight up"); + } + #[test] fn drag_moves_with_single_undo() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs index 41d86438e..f1f31643f 100644 --- a/crates/tools/src/slice.rs +++ b/crates/tools/src/slice.rs @@ -43,7 +43,7 @@ fn snap(cx: &ToolContext, p: Point) -> (Point, Vec) { cx.doc .guides .iter() - .filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_passes(g, p, tol)) + .filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_shown(g) && cx.doc.guide_passes(g, p, tol)) .min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs())) }; if let Some(g) = nearest(true, p.x) { diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 73d631f00..54099c817 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -313,7 +313,7 @@ impl Tool for FreeTransformTool { fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { let Some(r) = selection_bounds(cx) else { return Cursor::Arrow }; match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) { - Some(Op::Rotate) => Cursor::Rotate, + Some(Op::Rotate) => Cursor::rotate_about(r.center(), p), Some(Op::Move) => Cursor::Move, Some(Op::Scale(h) | Op::Shear(h)) => match h { Handle::Top | Handle::Bottom => Cursor::ResizeV, diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 99b77c4df..7591e4905 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -20,6 +20,7 @@ vectorcraft-tools = { workspace = true } vectorcraft-engine = { workspace = true } vectorcraft-svg = { workspace = true } vectorcraft-effects = { workspace = true } +vectorcraft-three-d = { workspace = true } vectorcraft-plugins = { workspace = true } vectorcraft-pdf = { workspace = true } vectorcraft-cad = { workspace = true } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f40007fb2..22fa00b26 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -21,7 +21,13 @@ fn main() { let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::().map_err(|e| e.to_string())) .unwrap_or_default(); let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs"); - if let Err(e) = std::fs::write(&out, generate(&json, &people)) { + let src = generate(&json, &people); + // Unchanged output keeps its mtime, so the next build doesn't compile this crate again + // (see crates/text/build.rs). + if std::fs::read_to_string(&out).is_ok_and(|old| old == src) { + return; + } + if let Err(e) = std::fs::write(&out, src) { println!("cargo::warning=writing {}: {e}", out.display()); } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ea2ed01bd..4e30d209f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -175,7 +175,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag()); // A press on the canvas while the context menu is open only closes it (a drag too, which egui // alone would leave open); the tool doesn't get it. - if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { + if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) { + // The artboard's 3D gizmo owns these gestures while its options are open. + // Do not let a ring drag also move or deselect the editable source path. + } else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { egui::Popup::close_all(ui.ctx()); } else { handle_input(app, ui, &resp, rect); @@ -375,7 +378,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Selected guides in the selection colour. let picked = LineLook { color: t.selection, ..look }; let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides); - for (i, g) in doc.guides.iter().enumerate() { + // Guides on a hidden layer hide with it. + for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) { let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) }); // An artboard guide runs across its artboard, a canvas guide across the window. let (a, b) = match doc.guide_span(g) { @@ -401,7 +405,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 +418,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 +428,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 { @@ -483,7 +488,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ResizeV => C::ResizeVertical, Cursor::ResizeNwSe => C::ResizeNwSe, Cursor::ResizeNeSw => C::ResizeNeSw, - Cursor::Rotate => C::Alias, + Cursor::Rotate | Cursor::RotateToward(_) => C::Alias, Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenJoin | Cursor::PenConvert => { C::Crosshair } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 46dc3b891..7f4d13c36 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -818,10 +818,14 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", theme::semibold(11.0), t.text); ui.add_space(6.0); let mut job = egui::text::LayoutJob::default(); - let tool = app.session.tool_id(); - // The Zoom tool's drag zooms to an area, or with Animated Zoom zooms as it goes. - let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool }; - for (txt, bold) in hint_segments(hint) { + let segments = if let Some(d) = app.ui.dialog.as_ref().filter(|d| crate::dialogs::revolve_gizmo::active(d)) { + vec![(crate::dialogs::revolve_gizmo::hint(d).into(), false)] + } else { + let tool = app.session.tool_id(); + let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool }; + hint_segments(hint) + }; + for (txt, bold) in segments { let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() }); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bdf2fd94a..6ca73099a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -16,8 +16,8 @@ //! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel` //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG //! comes back as `pngBase64` as well) -//! - `ui.render {path?, scale?, data?}`: render the active artboard headlessly (PNG; without a path, -//! or with `data: true`, as `pngBase64`) +//! - `ui.render {path?, scale?, artboard?, data?}`: render an artboard headlessly (`artboard`: its +//! 0-based index, default the first; PNG; without a path, or with `data: true`, as `pngBase64`) //! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit` //! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each //! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard) @@ -321,7 +321,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.render" => { let Some(st) = app.session.active() else { return err("no document") }; let doc = st.doc.clone(); - let Some(r) = doc.artboards.first().map(|a| a.rect) else { return err("no artboard") }; + // The artboard asked for (0-based, as headless), else the first. + let index = p.get("artboard").and_then(Value::as_u64).map_or(Some(0), |i| usize::try_from(i).ok()); + let Some(r) = index.and_then(|i| doc.artboards.get(i)).map(|a| a.rect) else { return err("no such artboard") }; let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0); if let Err(e) = vectorcraft_render::raster_size(r, scale) { return err(e); @@ -390,3 +392,42 @@ pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: None => json!({"ok": false, "error": "no writer configured"}), } } + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + use vectorcraft_engine::Session; + + use super::ControlRequest; + use crate::{Services, VectorcraftApp}; + + /// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked + /// for, at the scale asked for; the first without one. + #[test] + fn render_takes_the_artboard_asked_for() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx); + let mut request = |method: &str, params: Value| { + let (req, reply) = ControlRequest::new(method, params); + tx.send(req).unwrap(); + app.drain_control(&egui::Context::default()); + reply.try_recv().unwrap() + }; + let new = json!({"width": 64, "height": 64, "units": "Points", "artboards": 2}); + request("engine.execute", json!({"command": "file.new", "params": new})); + let board = json!({"index": 1, "x": 100, "y": 0, "width": 96, "height": 48}); + let r = request("engine.execute", json!({"command": "artboard.setProps", "params": board})); + assert_eq!(r["ok"], true, "{r}"); + for (params, size) in [ + (json!({}), [64, 64]), + (json!({"artboard": 0}), [64, 64]), + (json!({"artboard": 1}), [96, 48]), + (json!({"artboard": 1, "scale": 0.5}), [48, 24]), + ] { + let r = request("ui.render", params.clone()); + let got = [&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64); + assert_eq!(got, size.map(Some), "{params}: {r}"); + } + assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string()); + } +} diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index d98a2d2e8..f273af20b 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -113,17 +113,21 @@ fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) { } /// A curved double arrow over the hotspot: an arc with an arrowhead at each end, pointing on along -/// the arc (#701). -fn rotate(p: &mut Ink, o: Pos2) { +/// the arc (#701). The arc bows up, or toward `octant` (0 up, then clockwise in 45° steps): beside a +/// bounding box it wraps the corner the pointer is at. +fn rotate(p: &mut Ink, o: Pos2, octant: u8) { const R: f32 = 8.0; let (a0, a1) = (std::f32::consts::PI * 0.12, std::f32::consts::PI * 0.88); - let at = |a: f32| o + vec2(a.cos() * R, -a.sin() * R); + // Clockwise on screen (y down) by whole octants. + let (s, c) = (f32::from(octant % 8) * std::f32::consts::FRAC_PI_4).sin_cos(); + let turn = |v: Vec2| vec2(v.x * c - v.y * s, v.x * s + v.y * c); + let at = |a: f32| o + turn(vec2(a.cos() * R, -a.sin() * R)); let pts: Vec = (0..=12).map(|i| at(a0 + (a1 - a0) * i as f32 / 12.0)).collect(); p.add(Shape::line(pts.clone(), Stroke::new(3.2, HALO))); p.add(Shape::line(pts, Stroke::new(1.3, INK))); // Each head points away from the arc, along its tangent there. for (a, away) in [(a0, -1.0f32), (a1, 1.0)] { - let t = vec2(-a.sin(), -a.cos()) * away; + let t = turn(vec2(-a.sin(), -a.cos()) * away); let n = vec2(-t.y, t.x); let (end, tip) = (at(a) - t * 1.5, at(a) + t * 4.5); poly(p, vec![tip, end + n * 1.8, end - n * 1.8], INK, INK); @@ -269,7 +273,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)), Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)), Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)), - Cursor::Rotate => rotate(ink, p), + Cursor::Rotate => rotate(ink, p, 0), + Cursor::RotateToward(octant) => rotate(ink, p, octant), Cursor::CornerRadius => corner_radius(ink, p), Cursor::Pen => pen(ink, p, "*"), Cursor::PenAdd => pen(ink, p, "+"), @@ -432,7 +437,7 @@ mod tests { use crate::VectorcraftApp; /// Every cursor with a glyph. - const GLYPHS: [Cursor; 33] = [ + const GLYPHS: [Cursor; 34] = [ Cursor::Arrow, Cursor::ArrowHollow, Cursor::Move, @@ -442,6 +447,7 @@ mod tests { Cursor::ResizeNwSe, Cursor::ResizeNeSw, Cursor::Rotate, + Cursor::RotateToward(1), Cursor::CornerRadius, Cursor::Pen, Cursor::PenAdd, diff --git a/crates/ui-egui/src/dialogs/all_tools.rs b/crates/ui-egui/src/dialogs/all_tools.rs index 402e6f444..30584b5a3 100644 --- a/crates/ui-egui/src/dialogs/all_tools.rs +++ b/crates/ui-egui/src/dialogs/all_tools.rs @@ -10,11 +10,16 @@ use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, icons, widgets}; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: 560.0, ..DialogSpec::FORM }; +/// A compact drawer two tools wide, however wide the window (#1018); the dialog frame narrows it +/// further in a narrow window. +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: WIDTH, max_width: Some(WIDTH), ..DialogSpec::FORM }; /// A tool's cell: its icon and name. const CELL: egui::Vec2 = vec2(176.0, 28.0); const ICON: f32 = 20.0; +/// The drawer's width: two cells, the gap between them and room for the scroll bar. +const WIDTH: f32 = 2.0 * CELL.x + 24.0; /// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the /// Advanced toolbar shows. @@ -107,4 +112,26 @@ mod tests { assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}"); assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}"); } + + /// The drawer stays two tools wide in a wide window and fits a narrow one (#1018). + #[test] + fn all_tools_dialog_has_a_bounded_width() { + use egui::{Pos2, RawInput, Rect}; + + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.ui.dialog = Some(Dialog::new("allTools", serde_json::json!({}))); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + for width in [1280.0, 430.0] { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 900.0)); + for _ in 0..6 { + let mut out = ctx.run_ui(RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + } + let rect = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "allTools")))).expect("drawer is shown"); + assert!(rect.width() <= width, "drawer exceeds viewport: {rect:?}"); + assert!(rect.width() < 3.0 * CELL.x, "drawer wider than two tools: {rect:?}"); + } + } } diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index 11ed35716..473388342 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -1,7 +1,8 @@ //! The Color Picker (double-click a Fill/Stroke proxy, or `ui.colorPicker`): a colour field with a //! channel slider for H, S, B, R, G or B; HSB, RGB, Lab and CMYK fields and hex; Only Web Colors; //! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; and a Color -//! Swatches list. OK applies the colour to its proxy through `paint.setFill` / `paint.setStroke`. +//! Swatches list. With art selected the colour shows on it live as it is picked; OK keeps it as one +//! undo step through `paint.setFill` / `paint.setStroke`, Cancel puts the art back. //! //! Fields agents set with `ui.dialog.set`: `hex` (`"00FF00"`) or `color` (any colour the paint //! commands take), `channel` (`hue`, `saturation`, `brightness`, `red`, `green`, `blue`), `webOnly` @@ -15,6 +16,7 @@ use vectorcraft_doc::ColorMode; use vectorcraft_engine::cmd::color_value; use super::DialogSpec; +use super::form::PREVIEWED; use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe}; use crate::panels::{c32, color_json}; use crate::state::Dialog; @@ -23,8 +25,15 @@ use crate::{VectorcraftApp, widgets}; pub(super) const KIND: &str = "colorPicker"; -pub(super) const SPEC: DialogSpec = - DialogSpec { heading: |_| tl!("Color Picker").into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Color Picker").into(), + body, + confirm, + preview: true, + min_width: 600.0, + max_width: Some(640.0), + ..DialogSpec::FORM +}; /// Side of the colour field (and height of the channel slider). const FIELD: f32 = 240.0; @@ -242,9 +251,39 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { d.fields.insert("swatches".into(), json!(!swatches)); } store(d, &pick.unwrap_or(p).snapped(d.bool("webOnly"))); + preview(app, d); false } +/// Show the picked colour on the selected art while the picker is open: each new colour replaces +/// the last preview. Nothing previews until the colour first changes, nor without a selection +/// (the colour then only becomes the default paint, on OK). +fn preview(app: &mut VectorcraftApp, d: &mut Dialog) { + let (cmd, params) = paint_command(app, d); + let first = !app.session.in_interaction(); + let unchanged = d.fields.get(PREVIEWED) == Some(¶ms) || first && d.fields.get("original").and_then(color_value) == Some(picked(d).color); + let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()); + if unchanged || !selected { + return; + } + let _ = app.session.begin_interaction(if d.bool("stroke") { "Stroke Color" } else { "Fill Color" }); + if let Err(e) = app.session.preview(cmd, ¶ms) { + app.status(e.to_string()); + } + d.fields.insert(PREVIEWED.into(), params); +} + +/// The command that applies the picked colour to the proxy, and its params (as CMYK in a CMYK +/// document). +fn paint_command(app: &VectorcraftApp, d: &Dialog) -> (&'static str, Value) { + let mut c = picked(d).color; + if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) { + let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent); + c = Color::cmyk(cy, m, y, k); + } + (if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": color_json(&c) })) +} + /// The New (top) and Original (bottom) chips. Returns true when Original is clicked. fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool { let t = Tokens::get(ui.ctx()); @@ -363,15 +402,10 @@ fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color) -> Option Result { - let mut c = picked(d).color; - if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) { - let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent); - c = Color::cmyk(cy, m, y, k); - } - let cmd = if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" }; - super::run_and_close(app, cmd, json!({ "color": color_json(&c) })) + let (cmd, params) = paint_command(app, d); + super::form::commit_preview(app, cmd, params) } #[cfg(test)] @@ -453,6 +487,34 @@ mod tests { assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None); } + /// The selected art takes each colour as it is picked; OK keeps the last as one undo step and + /// Cancel puts the art back. + #[test] + fn the_selection_shows_the_colour_live() { + let mut app = app(); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let before = undo(&app); + app.run("ui.colorPicker", json!({})).unwrap(); + frame(&mut app); + assert!(!app.session.in_interaction(), "nothing previews before the colour changes"); + for hex in ["ff0000", "00ff00"] { + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex)); + frame(&mut app); + assert_eq!(fill_hex(&app), format!("#{hex}"), "live, before OK"); + } + super::super::confirm(&mut app).unwrap(); + assert_eq!(fill_hex(&app), "#00ff00"); + assert_eq!(undo(&app), before + 1, "one step"); + // Cancel: back to the colour it had. + app.run("ui.colorPicker", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff")); + frame(&mut app); + assert_eq!(fill_hex(&app), "#0000ff"); + super::super::cancel(&mut app); + assert_eq!(fill_hex(&app), "#00ff00"); + assert_eq!(undo(&app), before + 1); + } + #[test] fn open_set_hex_and_confirm_fills() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs/corners.rs b/crates/ui-egui/src/dialogs/corners.rs index 6924ee166..62bb6523a 100644 --- a/crates/ui-egui/src/dialogs/corners.rs +++ b/crates/ui-egui/src/dialogs/corners.rs @@ -27,8 +27,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Corners").int /// `corners` (default: the selected corners), filled in with their kind and radius. pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let st = app.session.active().ok_or("no document")?; - let id = match p.get("id").and_then(Value::as_u64) { - Some(id) => NodeId(id), + let id = match p.get("id") { + Some(v) => NodeId(v.as_u64().ok_or_else(|| format!("object ids are non-negative integers, not {v}"))?), None => match st.selection.objects[..] { [id] => id, _ => return Err("select one path, or give its `id`".into()), @@ -37,11 +37,15 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let Some(live) = st.doc.node(id).and_then(LiveCorners::of) else { return Err("Corners edits the corners of a path".into()); }; - let corners: BTreeSet = match p.get("corners").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_u64).filter_map(|k| usize::try_from(k).ok()).filter(|k| live.corner(*k).is_some()).collect(), + let corners: BTreeSet = match p.get("corners") { + Some(v) => v + .as_array() + .and_then(|a| a.iter().map(|k| usize::try_from(k.as_u64()?).ok()).collect()) + .ok_or_else(|| format!("`corners` must be an array of anchor indices, not {v}"))?, None => live.picked(st.selection.partial(id)), }; - if corners.is_empty() { + // Every corner given is one of the path's: none is left out of the edit. + if corners.is_empty() || !corners.iter().all(|k| live.corner(*k).is_some()) { return Err("give corners of the path: anchors between two straight sides".into()); } let (radius, kind) = live.style(&corners); @@ -149,6 +153,25 @@ mod tests { v } + /// Explicit UI-command parameters cannot silently fall back to the selection or drop corners. + #[test] + fn malformed_explicit_corner_targets_never_open_a_partial_edit() { + let (mut app, id) = app(); + for params in [ + json!({"id": "invalid"}), + json!({"id": -1}), + json!({"id": id, "corners": [0, "bad", 2]}), + json!({"id": id, "corners": [0, 1.5]}), + json!({"id": id, "corners": [0, 9]}), + json!({"id": id, "corners": "bad"}), + ] { + assert!(app.run("ui.corners", params.clone()).is_err(), "accepted {params}"); + assert!(app.ui.dialog.is_none(), "opened a partial edit for {params}"); + } + app.run("ui.corners", json!({"id": id, "corners": [0, 2]})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["corners"], json!([0, 2])); + } + #[test] fn double_clicking_a_direct_selected_corner_edits_it_alone() { let (mut app, id) = app(); diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index e1d21a604..e29175ff0 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -127,6 +127,12 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (id, relative) = (d.str("__effect"), d.bool("relative")); + if id == vectorcraft_effects::REVOLVE { + super::revolve::body(app, ui, d); + let (cmd, params) = command(d); + form::preview(app, ui, d, &d.str("__label"), cmd, params); + return false; + } // Plug-in effects get fields from their parameter schema; colour adjustments their sliders. let changed = match vectorcraft_plugins::effect::installed(&id) { Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params), @@ -159,6 +165,7 @@ fn choices(effect: &str, key: &str) -> Option { ("blur.radial", "method") => Some(&[("Spin", "spin"), ("Zoom", "zoom")]), ("blur.radial", "quality") => Some(&[("Draft", "draft"), ("Good", "good"), ("Best", "best")]), ("blur.smart", "quality") => Some(&[("Low", "low"), ("Medium", "medium"), ("High", "high")]), + ("brushStrokes.sprayedStrokes", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS), ("distort.glass", "texture") => Some(&vectorcraft_effects::pixel::GLASS_TEXTURES), ("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES), ("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES), diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index b1e9a9b75..2b3ace0d0 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -248,7 +248,7 @@ pub(super) fn param_fields( match v { Value::Number(n) => { if let Some(x) = crate::widgets::plain_field(ui, ("fx-num", &k), n.as_f64().unwrap_or(0.0), "", 3, 140.0) { - d.fields.insert(k, json!(x)); + d.fields.insert(k, typed_number(x)); changed = true; } } @@ -291,6 +291,13 @@ pub(super) fn param_fields( changed } +/// A number typed in a command's dialog as JSON: a whole number as an integer, as counts are read +/// (`as_u64`: Object Mosaic's columns and rows, #1000), which `12.0` isn't; a fraction stays one. +/// Commands that take any number read either. +fn typed_number(x: f64) -> Value { + if x.fract() == 0.0 && x.abs() < 1e15 { json!(x as i64) } else { json!(x) } +} + /// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers /// within their range, whole numbers, checkboxes and dropdowns, in declaration order. Returns /// true when a value changed. The labels (from the parameter keys) and choices are the plug-in's @@ -552,3 +559,19 @@ pub(super) fn caption(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tl!(text)).size(11.0).color(t.text_dim)); } + +#[cfg(test)] +mod typed_number_tests { + use super::*; + + #[test] + fn whole_numbers_are_integers_and_fractions_stay_fractions() { + // Whatever the field held before: a count typed after a fraction is a count again. + assert_eq!(typed_number(24.0).as_u64(), Some(24)); + assert_eq!(typed_number(-4.0), json!(-4)); + assert_eq!(typed_number(0.0), json!(0)); + assert_eq!(typed_number(2.5), json!(2.5)); + // Numbers past exact integers stay floats. + assert!(typed_number(1e20).is_f64()); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 78e7fd8b7..09b9c290e 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -67,6 +67,8 @@ pub mod raster_effects; pub mod rearrange_artboards; pub mod recolor; mod recovery; +mod revolve; +pub(crate) mod revolve_gizmo; pub mod saturate; mod save_changes; pub mod save_for_web; @@ -355,6 +357,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { // its first field is to take the keyboard focus (`focus_id`, until a field takes it). let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending")); let Some(mut d) = app.ui.dialog.clone() else { + revolve_gizmo::clear(ctx); app.ui.dialog_file = None; ctx.data_mut(|m| { m.remove::(shown_id); @@ -362,6 +365,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); return; }; + if !revolve_gizmo::active(&d) { + revolve_gizmo::clear(ctx); + } let focus_first = ctx.data_mut(|m| { if m.get_temp::(shown_id).as_deref() != Some(d.kind.as_str()) { m.insert_temp(shown_id, d.kind.clone()); @@ -377,7 +383,14 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let mut cancel = false; let mut discard = false; let heading = (spec.heading)(&d); - modal::show(ctx, &heading, egui::Id::new(("dialog", d.kind.as_str())), -40.0, MARGIN, |ui| { + let revolves = revolve_gizmo::active(&d); + if revolves { + revolve_gizmo::show(app, ctx, &mut d); + } + let initial = if revolves { egui::vec2((ctx.content_rect().width() / 2.0 - 226.0).max(0.0), -40.0) } else { egui::vec2(0.0, -40.0) }; + let id = egui::Id::new(("dialog", d.kind.as_str())); + let id = if revolves { id.with("revolve") } else { id }; + modal::show_at(ctx, &heading, id, initial, MARGIN, |ui| { // Never wider than the window (a large UI scale in a small window): the text wraps. let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP); ui.set_min_width(spec.min_width.min(room)); diff --git a/crates/ui-egui/src/dialogs/modal.rs b/crates/ui-egui/src/dialogs/modal.rs index dcd415739..164471fc7 100644 --- a/crates/ui-egui/src/dialogs/modal.rs +++ b/crates/ui-egui/src/dialogs/modal.rs @@ -11,6 +11,11 @@ use crate::theme::{self, Tokens}; /// screen's centre until it is moved, with `margin` inside its frame. `add` draws the contents and /// should start with [`heading`] (or [`drag_band`]) so the window can be moved. pub(crate) fn show(ctx: &egui::Context, title: &str, id: Id, nudge: f32, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option { + show_at(ctx, title, id, egui::vec2(0.0, nudge), margin, add) +} + +/// A modal that starts at a chosen offset, then keeps the user's dragged position. +pub(crate) fn show_at(ctx: &egui::Context, title: &str, id: Id, initial: Vec2, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option { let t = Tokens::get(ctx); let dim = backdrop(); egui::Area::new(dim.id).order(dim.order).fixed_pos(egui::Pos2::ZERO).show(ctx, |ui| { @@ -23,7 +28,7 @@ pub(crate) fn show(ctx: &egui::Context, title: &str, id: Id, nudge: f32, marg .collapsible(false) .resizable(false) .title_bar(false) - .anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, nudge)) + .anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, initial)) .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin))) .show(ctx, add) .and_then(|r| r.inner) @@ -67,9 +72,9 @@ fn handle(id: Id) -> Id { /// its [`drag_band`] is dragged, where it sat when the drag began plus how far the pointer has moved /// since the press, kept on screen (applied before the window places itself, so it follows the /// pointer without a frame's lag). -fn offset(ctx: &egui::Context, id: Id, nudge: f32) -> Vec2 { +fn offset(ctx: &egui::Context, id: Id, initial: Vec2) -> Vec2 { let (key, start_key) = (id.with("modal-offset"), id.with("modal-drag-start")); - let offset = ctx.data(|m| m.get_temp::(key)).unwrap_or(egui::vec2(0.0, nudge)); + let offset = ctx.data(|m| m.get_temp::(key)).unwrap_or(initial); let dragged = ctx.read_response(handle(id)).is_some_and(|r| r.dragged()); let moved = ctx.input(|i| Some(i.pointer.latest_pos()? - i.pointer.press_origin()?)); let (true, Some(moved), Some(rect)) = (dragged, moved, ctx.memory(|m| m.area_rect(id))) else { diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs new file mode 100644 index 000000000..2d2b09ccc --- /dev/null +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -0,0 +1,233 @@ +//! Compact Revolve controls. The object and rotation gizmo live on the artboard. +//! All document changes run through the effect dialog's existing preview/commit commands. + +use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2}; +use serde_json::{Value, json}; +use vectorcraft_doc::Unit; +use vectorcraft_effects::REVOLVE; + +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, scrub, widgets}; + +const LABEL_W: f32 = 102.0; +const VALUE_W: f32 = 74.0; +const SIDE_W: f32 = 354.0; + +/// Sized to the screen, with scrolling inside the body so OK and Cancel stay reachable. +pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + // Values start in drag mode; clicking or Tab enters their text editor. + ui.data_mut(|m| m.remove::(widgets::dialog_focus_flag())); + let screen = ui.ctx().content_rect(); + let width = (screen.width() - 64.0).clamp(240.0, SIDE_W); + let height = (screen.height() - 240.0).clamp(120.0, 340.0); + ui.set_width(width); + egui::ScrollArea::vertical().id_salt("revolve-body").max_height(height).auto_shrink([false, true]).show(ui, |ui| { + controls(app, ui, d); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + let t = Tokens::get(ui.ctx()); + ui.allocate_ui_with_layout(vec2((width - 100.0).max(80.0), 32.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add(egui::Label::new(egui::RichText::new(super::revolve_gizmo::hint(d)).size(11.0).color(t.text_dim)).wrap()); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let defaults = vectorcraft_effects::default_params(REVOLVE).unwrap_or(json!({})); + let changed = defaults.as_object().is_some_and(|m| { + m.iter().any(|(k, v)| v.as_f64().map_or_else(|| d.fields.get(k) != Some(v), |default| (d.f64(k, default) - default).abs() > 1e-6)) + }); + let (r, _) = ui.allocate_exact_size(vec2(88.0, 24.0), Sense::hover()); + let response = ui.interact(r, Id::new("revolve-reset-all"), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset all"))); + if response.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + ui.painter().text(r.left_center() + vec2(4.0, 0.0), Align2::LEFT_CENTER, tl!("Reset all"), FontId::proportional(11.5), t.text); + dot(ui, r.right_center() - vec2(10.0, 0.0), changed); + if response.on_hover_text(tl!("Reset all Revolve settings to their defaults.")).clicked() + && let Some(defaults) = defaults.as_object() + { + d.fields.extend(defaults.clone()); + } + }); + }); +} + +fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + ui.spacing_mut().item_spacing.y = 4.0; + let lighting = super::revolve_gizmo::lighting(d); + if let Some(tab) = widgets::tab_bar(ui, &[tl!("Rotation"), tl!("Lighting")], usize::from(lighting)) { + d.fields.insert("__gizmo".into(), json!(if tab == 1 { "light" } else { "rotation" })); + } + ui.add_space(8.0); + if super::revolve_gizmo::lighting(d) { + lighting_controls(ui, d); + return; + } + let unit = app.session.general_unit(); + heading(ui, tl!("Shape")); + number(ui, d, "angle", tl!("Revolve angle"), (0.0, 360.0), "°", 1, None); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + label(ui, tl!("Axis side")); + let edge = d.str("edge"); + let current = if edge == "right" { tl!("Right") } else { tl!("Left") }; + let width = VALUE_W + rail_width(ui) + 6.0; + if let Some(k) = widgets::dropdown(ui, "revolve-edge", current, &[tl!("Left"), tl!("Right")], width) { + d.fields.insert("edge".into(), json!(if k == 0 { "left" } else { "right" })); + } + if reset_dot(ui, "edge", edge != "left", tl!("Left")) { + d.fields.insert("edge".into(), json!("left")); + } + }); + number(ui, d, "offset", tl!("Offset"), (0.0, 100000.0), "", 1, Some(unit)); + ui.add_space(10.0); + heading(ui, tl!("View")); + for (key, name) in [("rotationX", tl!("Rotation X")), ("rotationY", tl!("Rotation Y")), ("rotationZ", tl!("Rotation Z"))] { + number(ui, d, key, name, (-360.0, 360.0), "°", 1, None); + } + number(ui, d, "perspective", tl!("Perspective"), (0.0, 100.0), "%", 1, None); + ui.add_space(6.0); + egui::CollapsingHeader::new(tl!("Advanced")).id_salt("revolve-advanced").show(ui, |ui| { + number(ui, d, "segments", tl!("Segments"), (8.0, 128.0), "", 0, None); + super::form::check(ui, d, "__showAxis", tl!("Show axis")); + ui.add_space(6.0); + ui.horizontal(|ui| { + let mut visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true); + let response = ui.allocate_ui_with_layout( + vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.checkbox(&mut visible, egui::RichText::new(tl!("Keep visible surfaces only when expanding")).size(12.0)) + }, + ).inner; + if response.on_hover_text(tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.")).changed() { + d.fields.insert("expandVisibleOnly".into(), json!(visible)); + } + if reset_dot(ui, "expandVisibleOnly", !visible, "true") { + d.fields.insert("expandVisibleOnly".into(), json!(true)); + } + }); + }); +} + +fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + let mut shade = d.bool("shade"); + if ui.checkbox(&mut shade, tl!("Shading")).changed() { + d.fields.insert("shade".into(), json!(shade)); + } + ui.add_space((ui.available_width() - 24.0).max(0.0)); + if reset_dot(ui, "shade", !shade, "true") { + d.fields.insert("shade".into(), json!(true)); + } + }); + ui.add_enabled_ui(d.bool("shade"), |ui| { + number(ui, d, "lightAzimuth", tl!("Direction"), (-360.0, 360.0), "°", 1, None); + number(ui, d, "lightElevation", tl!("Elevation"), (-90.0, 90.0), "°", 1, None); + number(ui, d, "lightIntensity", tl!("Intensity"), (0.0, 100.0), "%", 1, None); + number(ui, d, "ambient", tl!("Ambient"), (0.0, 100.0), "%", 1, None); + }); +} + +fn heading(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).font(crate::theme::semibold(13.0)).color(t.text)); +} + +fn label(ui: &mut egui::Ui, name: &str) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 24.0), Sense::hover()); + ui.put(r, egui::Label::new(egui::RichText::new(name).size(12.5).color(t.text)).halign(egui::Align::Min).truncate()).on_hover_text(name); + scrub::note_label(ui, r); +} + +fn rail_width(ui: &egui::Ui) -> f32 { + (ui.available_width() - VALUE_W - 24.0 - 18.0).clamp(40.0, 134.0) +} + +/// A value can be typed, dragged, scrubbed by its label, or moved on its slider. +#[allow(clippy::too_many_arguments)] +fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option) { + let default = vectorcraft_effects::default_params(REVOLVE).and_then(|p| p.get(key).and_then(Value::as_f64)).unwrap_or(0.0); + let from = |n| unit.map_or(n, |u| u.from_pt(n)); + let to = |n| unit.map_or(n, |u| u.to_pt(n)); + let typed = unit.map(|u| widgets::typed_unit(ui.ctx(), u)); + let mut value = from(d.f64(key, default)).clamp(from(range.0), from(range.1)); + let suffix = unit.map_or_else(|| suffix.to_string(), |u| format!(" {}", u.suffix())); + let mut next = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + label(ui, name); + let t = Tokens::get(ui.ctx()); + let response = ui + .scope(|ui| { + ui.visuals_mut().widgets.inactive.weak_bg_fill = t.input; + ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border); + ui.add_sized( + [VALUE_W, 24.0], + egui::DragValue::new(&mut value) + .range(from(range.0)..=from(range.1)) + .speed(if decimals == 0 { 1.0 } else { 0.25 }) + .max_decimals(decimals) + .suffix(&suffix) + .update_while_editing(false) + .custom_parser(|s| { + unit.map_or_else( + || vectorcraft_doc::parse_number(&s.replace(['°', '%'], "")), + |u| typed.unwrap_or(u).parse(s).map(|v| u.from_pt(v)), + ) + }), + ) + }) + .inner; + response.clone().on_hover_text(tl!("Drag values or labels. Double-click a value to type.")); + if response.changed() { + next = Some(value); + } + if let Some(n) = scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32) { + next = Some(n); + } + // Offset's rail covers a useful working range; larger values can still be typed. + let rail_max = if key == "offset" { from(500.0) } else { from(range.1) }; + let rail_min = from(range.0); + let rail = rail_width(ui) + VALUE_W; + let slider = + widgets::color_slider(ui, ("revolve-slider", key), ((value - rail_min) / (rail_max - rail_min)).clamp(0.0, 1.0) as f32, rail, &|_| { + t.input_border + }); + if let (Some(n), _) = slider { + next = Some(rail_min + f64::from(n) * (rail_max - rail_min)); + } + if reset_dot(ui, key, (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) { + next = Some(from(default)); + } + }); + if let Some(n) = next.filter(|n| n.is_finite()) { + let factor = 10f64.powi(decimals as i32); + let n = (n * factor).round() / factor; + d.fields.insert(key.into(), json!(to(n.clamp(from(range.0), from(range.1))))); + } +} + +fn dot(ui: &egui::Ui, p: Pos2, changed: bool) { + let t = Tokens::get(ui.ctx()); + if changed { + ui.painter().circle_filled(p, 3.0, if ui.is_enabled() { t.accent } else { t.text_disabled }); + } else { + ui.painter().circle_stroke(p, 3.0, Stroke::new(1.0, t.text_disabled)); + } +} + +fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool { + let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); + let response = ui.interact(r, Id::new(("revolve-reset", key)), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset to default"))); + if response.hovered() { + ui.painter().rect_filled(r, 3.0, Tokens::get(ui.ctx()).hover); + } + dot(ui, r.center(), changed); + response.on_hover_text(format!("{}: {default}", tl!("Reset to default"))).clicked() +} + +#[cfg(test)] +#[path = "tests_revolve.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs new file mode 100644 index 000000000..e00e1377f --- /dev/null +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -0,0 +1,425 @@ +//! Artboard rotation and light controls for the active Revolve dialog. Gestures edit fields; +//! the existing effect preview applies commands and owns OK, Cancel and undo. +use egui::{Align2, FontId, Id, Pos2, Rect, Sense, Stroke, Vec2, vec2}; +use serde_json::json; +use vectorcraft_effects::{REVOLVE, revolve_options}; +use vectorcraft_geom::Point; +use vectorcraft_three_d::Revolve; + +use crate::{VectorcraftApp, canvas::Xf, state::Dialog, theme::Tokens}; + +const KEYS: [&str; 3] = ["rotationX", "rotationY", "rotationZ"]; +const SAMPLES: usize = 96; + +pub(crate) fn active(d: &Dialog) -> bool { + d.kind == "effect" && d.str("__effect") == REVOLVE +} + +pub(crate) fn lighting(d: &Dialog) -> bool { + d.str("__gizmo") == "light" +} + +pub(crate) fn hint(d: &Dialog) -> &'static str { + if !d.bool("preview") || (lighting(d) && !d.bool("shade")) { + tl!("Drag values or labels. Double-click a value to type.") + } else if lighting(d) { + tl!("Drag the light, or its rings. Shift snaps to 15°.") + } else { + tl!("Drag rings to rotate. Shift snaps to 15°.") + } +} + +fn drag_id() -> Id { + Id::new("revolve-gizmo-drag") +} + +pub(crate) fn dragging(ctx: &egui::Context) -> bool { + ctx.data(|m| m.get_temp::(drag_id())).is_some() +} + +pub(super) fn clear(ctx: &egui::Context) { + ctx.data_mut(|m| m.remove::(drag_id())); +} + +#[derive(Clone, Copy)] +struct Ring { + center: Vec2, + u: Vec2, + v: Vec2, + depth: [f32; 2], +} + +impl Ring { + fn point(self, angle: f32) -> Vec2 { + self.center + self.u * angle.cos() + self.v * angle.sin() + } + fn tangent(self, angle: f32) -> Vec2 { + -self.u * angle.sin() + self.v * angle.cos() + } + fn phase(self, p: Vec2) -> Option { + let p = p - self.center; + let det = self.u.x * self.v.y - self.u.y * self.v.x; + // Near edge-on, an ellipse's inverse magnifies tiny pointer movements. + if det.abs() < self.u.length().max(self.v.length()).powi(2) * 0.15 { + return None; + } + let x = (p.x * self.v.y - p.y * self.v.x) / det; + let y = (p.y * self.u.x - p.x * self.u.y) / det; + (x.hypot(y) > 0.1).then(|| y.atan2(x)) + } +} + +#[derive(Clone, Copy)] +struct Geometry { + center: Pos2, + radius: f32, + rings: [Ring; 3], + xf: Xf, + bounds: vectorcraft_geom::Rect, +} + +impl Geometry { + fn new(app: &VectorcraftApp, d: &Dialog) -> Option { + let state = app.session.active()?; + let node = state.doc.node(*state.selection.objects.first()?)?; + let bounds = node.geometric_bounds()?; + let xf = Xf::new(app.canvas_rect?, app.view()?); + let options = revolve_options(&super::form::params(d)); + let center = xf.to_screen(options.rotation_origin(bounds)); + if !center.x.is_finite() || !center.y.is_finite() || !xf.rect.contains(center) { + return None; + } + let radius = (bounds.height().abs() * xf.zoom * 0.24).clamp(76.0, 116.0) as f32; + let (s, c) = (xf.rot as f32).sin_cos(); + let screen = |p: [f64; 3], scale: f32| vec2(c * p[0] as f32 - s * p[1] as f32, s * p[0] as f32 + c * p[1] as f32) * radius * scale; + let rings = std::array::from_fn(|i| { + let [u, v] = options.rotation_planes()[i]; + let scale = if i == 2 { 1.12 } else { 1.0 }; + Ring { center: Vec2::ZERO, u: screen(u, scale), v: screen(v, scale), depth: [u[2] as f32, v[2] as f32] } + }); + Some(Self { center, radius, rings, xf, bounds }) + } + + fn hit(self, p: Pos2) -> Option<(usize, f32)> { + let mut best = None; + let mut distance = 9.0; + for (axis, ring) in self.rings.iter().enumerate() { + for i in 0..SAMPLES { + let a = i as f32 * std::f32::consts::TAU / SAMPLES as f32; + let b = a + std::f32::consts::TAU / SAMPLES as f32; + let (start, end) = (self.center + ring.point(a), self.center + ring.point(b)); + let line = end - start; + let t = ((p - start).dot(line) / line.length_sq().max(1e-6)).clamp(0.0, 1.0); + let d = p.distance(start + line * t); + if d < distance { + distance = d; + best = Some((axis, a + (b - a) * t)); + } + } + } + best + } + + fn light_rings(self, options: Revolve) -> [Ring; 2] { + let (s, c) = (self.xf.rot as f32).sin_cos(); + let screen = |p: [f64; 3]| vec2(c * p[0] as f32 - s * p[1] as f32, s * p[0] as f32 + c * p[1] as f32) * self.radius * 1.4; + options.light_orbits().map(|[center, u, v]| Ring { center: screen(center), u: screen(u), v: screen(v), depth: [u[2] as f32, v[2] as f32] }) + } + + fn light_labels(self) -> [Rect; 2] { + [ + Rect::from_center_size(self.center + vec2(0.0, self.radius * 1.4 + 24.0), vec2(88.0, 26.0)), + Rect::from_center_size(self.center - vec2(self.radius * 1.4 + 32.0, 0.0), vec2(78.0, 26.0)), + ] + } + + fn light_hit(self, p: Pos2, options: Revolve) -> Option { + if let Some(axis) = self.light_labels().iter().position(|r| r.contains(p)) { + return Some(axis); + } + let mut best = None; + let mut distance = 9.0; + for (axis, ring) in self.light_rings(options).iter().enumerate() { + let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (-std::f32::consts::FRAC_PI_2, std::f32::consts::PI) }; + for i in 0..SAMPLES { + let a = start + i as f32 * span / SAMPLES as f32; + let (from, to) = (self.center + ring.point(a), self.center + ring.point(a + span / SAMPLES as f32)); + let line = to - from; + let t = ((p - from).dot(line) / line.length_sq().max(1e-6)).clamp(0.0, 1.0); + let d = p.distance(from + line * t); + if d < distance { + distance = d; + best = Some(axis); + } + } + } + best + } +} + +#[derive(Clone, Copy)] +enum Mode { + Ring { axis: usize, ring: Ring, phase: f32, degrees: f64 }, + Orbit, + Light, + LightRing { axis: usize }, +} + +#[derive(Clone)] +struct Drag { + mode: Mode, + geometry: Geometry, + last: Pos2, + origin: Pos2, + fields: serde_json::Map, +} + +fn wrap(n: f64, snap: bool) -> f64 { + let n = if snap { (n / 15.0).round() * 15.0 } else { n }; + (n + 180.0).rem_euclid(360.0) - 180.0 +} + +pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) { + let light_mode = lighting(d); + if !d.bool("preview") || (light_mode && !d.bool("shade")) { + clear(ctx); + return; + } + let Some(geometry) = Geometry::new(app, d) else { return }; + let geometry = ctx.data(|m| m.get_temp::(drag_id())).map_or(geometry, |drag| drag.geometry); + let mut rect = Rect::from_center_size(geometry.center, Vec2::splat(geometry.radius * 3.0 + 48.0)); + if light_mode { + for label in geometry.light_labels() { + rect = rect.union(label.expand(2.0)); + } + } + egui::Area::new(Id::new("revolve-gizmo-area")).order(egui::Order::Foreground).fixed_pos(rect.min).movable(false).show(ctx, |ui| { + ui.set_clip_rect(geometry.xf.rect); + let response = ui.allocate_rect(rect.intersect(geometry.xf.rect), Sense::drag()); + let options = revolve_options(&super::form::params(d)); + let light = light_position(geometry, options); + let hovered = response + .hovered() + .then(|| response.hover_pos()) + .flatten() + .and_then(|p| if light_mode { geometry.light_hit(p, options).map(|axis| (axis, 0.0)) } else { geometry.hit(p) }); + if response.drag_started() + && let Some(origin) = ui.input(|i| i.pointer.press_origin()) + { + let mode = if light_mode { + if origin.distance(light) <= 18.0 { + Mode::Light + } else if let Some(axis) = geometry.light_hit(origin, options) { + Mode::LightRing { axis } + } else { + Mode::Light + } + } else if origin.distance(geometry.center) <= 14.0 { + Mode::Orbit + } else if let Some((axis, phase)) = geometry.hit(origin) { + Mode::Ring { axis, ring: geometry.rings[axis], phase, degrees: 0.0 } + } else { + Mode::Orbit + }; + ui.ctx().memory_mut(|m| m.stop_text_input()); + ui.data_mut(|m| m.insert_temp(drag_id(), Drag { mode, geometry, last: origin, origin, fields: d.fields.clone() })); + } + let mut drag = ui.data(|m| m.get_temp::(drag_id())); + if let Some(current) = &mut drag { + if ui.input_mut(|i| i.consume_key(i.modifiers, egui::Key::Escape)) { + d.fields = current.fields.clone(); + d.fields.remove(super::form::PREVIEWED); + ui.data_mut(|m| m.remove::(drag_id())); + drag = None; + } else if let Some(p) = response.interact_pointer_pos() { + let snap = ui.input(|i| i.modifiers.shift); + match &mut current.mode { + Mode::Ring { axis, ring, phase, degrees } => { + let step = if let Some(next) = ring.phase(p - geometry.center) { + let step = (next - *phase + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI; + *phase = next; + step + } else { + let tangent = ring.tangent(*phase); + let step = (p - current.last).dot(tangent) / tangent.length_sq().max(144.0); + *phase += step; + step + }; + *degrees += f64::from(step.to_degrees()); + if let Some(key) = KEYS.get(*axis) { + let start = current.fields.get(*key).and_then(|v| v.as_f64()).unwrap_or(0.0); + d.fields.insert((*key).into(), json!(wrap(start + *degrees, snap))); + } + } + Mode::Orbit => { + let delta = geometry.xf.delta_to_doc(p - current.origin) * geometry.xf.zoom; + for (key, delta) in [("rotationX", delta.y * 0.5), ("rotationY", delta.x * 0.5)] { + let start = current.fields.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0); + d.fields.insert(key.into(), json!(wrap(start + delta, snap))); + } + } + Mode::Light => { + let start = revolve_options(&serde_json::Value::Object(current.fields.clone())); + let projected = light_position(geometry, start) + (p - current.origin); + let point = geometry.xf.delta_to_doc(projected - geometry.center) * (geometry.xf.zoom / f64::from(geometry.radius * 1.4)); + let [azimuth, elevation] = start.light_at_on_side(Point::new(point.x, point.y)); + d.fields.insert("lightAzimuth".into(), json!(wrap(azimuth, snap))); + d.fields.insert( + "lightElevation".into(), + json!((if snap { (elevation / 15.0).round() * 15.0 } else { elevation }).clamp(-90.0, 90.0)), + ); + } + Mode::LightRing { axis } => { + let delta = geometry.xf.delta_to_doc(p - current.origin) * geometry.xf.zoom; + let start = revolve_options(&serde_json::Value::Object(current.fields.clone())); + if *axis == 0 { + d.fields.insert("lightAzimuth".into(), json!(wrap(start.light_azimuth + delta.x * 0.6, snap))); + } else { + let elevation = start.light_elevation - delta.y * 0.6; + d.fields.insert( + "lightElevation".into(), + json!((if snap { (elevation / 15.0).round() * 15.0 } else { elevation }).clamp(-90.0, 90.0)), + ); + } + } + } + current.last = p; + ui.data_mut(|m| m.insert_temp(drag_id(), current.clone())); + } + } + if response.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + ui.data_mut(|m| m.remove::(drag_id())); + } + if response.hovered() || drag.is_some() { + ui.ctx().set_cursor_icon(if drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + let active = match drag.as_ref().map(|d| d.mode) { + Some(Mode::Ring { axis, .. }) => Some(axis), + Some(Mode::LightRing { axis }) => Some(axis), + _ => hovered.map(|h| h.0), + }; + paint(ui, d, Geometry::new(app, d).unwrap_or(geometry), active, drag.as_ref()); + response.on_hover_text(hint(d)); + }); +} + +fn light_position(g: Geometry, options: Revolve) -> Pos2 { + let [x, y, _] = options.light_direction(); + let (s, c) = (g.xf.rot as f32).sin_cos(); + g.center + vec2(c * x as f32 - s * y as f32, s * x as f32 + c * y as f32) * g.radius * 1.4 +} + +fn paint(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option, drag: Option<&Drag>) { + if lighting(d) { + paint_light(ui, d, g, active, drag); + return; + } + let t = Tokens::get(ui.ctx()); + let painter = ui.painter(); + let colors = [t.axis_x, t.axis_y, t.axis_z]; + for (axis, ring) in g.rings.iter().enumerate() { + let color = if active == Some(axis) { t.warning } else { colors[axis] }; + for i in 0..SAMPLES { + let angle = i as f32 * std::f32::consts::TAU / SAMPLES as f32; + let behind = ring.depth[0] * angle.cos() + ring.depth[1] * angle.sin() < -0.05; + if behind && i % 3 == 0 { + continue; + } + let points = [g.center + ring.point(angle), g.center + ring.point(angle + std::f32::consts::TAU / SAMPLES as f32)]; + painter.line_segment(points, Stroke::new(4.0, t.input.gamma_multiply(0.55))); + painter.line_segment( + points, + Stroke::new(if active == Some(axis) { 2.5 } else { 1.75 }, color.gamma_multiply(if behind { 0.55 } else { 1.0 })), + ); + } + // Letters make the axes distinguishable without relying on colour alone. + let phase = match axis { + 0 => 0.75, + 1 => 2.25, + _ => 4.7, + }; + let p = g.center + ring.point(phase); + painter.circle_filled(p, 9.0, t.panel); + painter.text(p, Align2::CENTER_CENTER, ["X", "Y", "Z"][axis], FontId::proportional(11.0), color); + } + painter.circle(g.center, 8.0, t.panel.gamma_multiply(0.8), Stroke::new(1.5, t.text_dim)); + painter.line_segment([g.center - vec2(4.0, 0.0), g.center + vec2(4.0, 0.0)], Stroke::new(1.0, t.text_dim)); + painter.line_segment([g.center - vec2(0.0, 4.0), g.center + vec2(0.0, 4.0)], Stroke::new(1.0, t.text_dim)); + let options = revolve_options(&super::form::params(d)); + if d.bool("__showAxis") { + painter.line_segment(options.projected_axis(g.bounds).map(|p| g.xf.to_screen(p)), Stroke::new(1.0, t.warning)); + } + if let Some(Drag { mode: Mode::Ring { axis, .. }, .. }) = drag + && let Some(key) = KEYS.get(*axis) + { + let text = format!("{} {:.1}°", ["X", "Y", "Z"].get(*axis).unwrap_or(&""), d.f64(key, 0.0)); + let pos = g.center + vec2(0.0, g.radius * 1.12 + 18.0); + let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.text_strong); + painter.rect_filled(Rect::from_center_size(pos, galley.size() + vec2(14.0, 8.0)), 4.0, t.panel); + painter.galley(pos - galley.size() * 0.5, galley, t.text_strong); + } +} + +fn paint_light(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option, drag: Option<&Drag>) { + let t = Tokens::get(ui.ctx()); + let painter = ui.painter(); + let options = revolve_options(&super::form::params(d)); + let radius = g.radius * 1.4; + painter.circle_stroke(g.center, radius, Stroke::new(1.0, t.text_dim.gamma_multiply(0.3))); + let labels = g.light_labels(); + for (axis, ring) in g.light_rings(options).iter().enumerate() { + let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (-std::f32::consts::FRAC_PI_2, std::f32::consts::PI) }; + for i in 0..SAMPLES { + let a = start + i as f32 * span / SAMPLES as f32; + let behind = ring.depth[0] * a.cos() + ring.depth[1] * a.sin() < -0.05; + if behind && i % 3 == 0 { + continue; + } + let points = [g.center + ring.point(a), g.center + ring.point(a + span / SAMPLES as f32)]; + painter.line_segment(points, Stroke::new(4.0, t.panel.gamma_multiply(0.4))); + painter.line_segment( + points, + Stroke::new(if active == Some(axis) { 2.5 } else { 1.5 }, t.warning.gamma_multiply(if behind { 0.45 } else { 0.85 })), + ); + } + let label = labels[axis]; + painter.rect_filled(label, 4.0, if active == Some(axis) { t.hover } else { t.panel }); + let text = if axis == 0 { tl!("Direction") } else { tl!("Elevation") }; + painter.text(label.center(), Align2::CENTER_CENTER, text, FontId::proportional(11.0), t.warning); + } + let p = light_position(g, options); + let delta = g.center - p; + if delta.length() > 24.0 { + let direction = delta.normalized(); + let normal = vec2(-direction.y, direction.x); + let tip = g.center - direction * 12.0; + painter.line_segment([p, tip], Stroke::new(1.25, t.warning.gamma_multiply(0.6))); + painter.line_segment([tip, tip - direction * 8.0 + normal * 4.0], Stroke::new(1.25, t.warning)); + painter.line_segment([tip, tip - direction * 8.0 - normal * 4.0], Stroke::new(1.25, t.warning)); + } + painter.circle(g.center, 4.0, t.panel, Stroke::new(1.0, t.text_dim)); + // An original sun handle. A back light is hollow; the guide's back half is dashed. + let front = options.light_direction()[2] >= 0.0; + painter.circle(p, 9.0, if front { t.warning } else { t.panel }, Stroke::new(2.0, t.warning)); + for i in 0..8 { + let angle = i as f32 * std::f32::consts::TAU / 8.0; + let direction = vec2(angle.cos(), angle.sin()); + painter.line_segment([p + direction * 13.0, p + direction * 17.0], Stroke::new(1.5, t.warning)); + } + let name = crate::i18n::tr_ctx(crate::i18n::current(), "lighting", "Light"); + let galley = painter.layout_no_wrap(name.to_string(), FontId::proportional(11.0), t.warning); + let name_pos = p - vec2(galley.size().x * 0.5, 25.0 + galley.size().y); + painter.rect_filled(Rect::from_min_size(name_pos - vec2(4.0, 2.0), galley.size() + vec2(8.0, 4.0)), 3.0, t.panel); + painter.galley(name_pos, galley, t.warning); + if drag.is_some() { + let text = format!("{} {:.1}° {} {:.1}°", tl!("Direction"), d.f64("lightAzimuth", -45.0), tl!("Elevation"), d.f64("lightElevation", 45.0)); + let galley = painter.layout_no_wrap(text, FontId::proportional(11.5), t.text_strong); + let pos = g.center + vec2(0.0, radius + 56.0); + painter.rect_filled(Rect::from_center_size(pos, galley.size() + vec2(12.0, 6.0)), 4.0, t.panel); + painter.galley(pos - galley.size() * 0.5, galley, t.text_strong); + } +} + +#[cfg(test)] +#[path = "tests_revolve_gizmo.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 14358684d..f0df5c695 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -228,6 +228,26 @@ fn effect_dialog_applies_to_its_appearance_item() { assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty()); } +#[test] +fn revolve_dialog_preview_cancel_confirm_and_undo() { + let mut app = app(); + app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + app.run("effect.dialog", json!({"effect":"threeD.revolve","item":null})).unwrap(); + frame(&mut app, Default::default()); + assert!(app.session.in_interaction()); + cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("effect.dialog", json!({"effect":"threeD.revolve","item":null})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("angle".into(), json!(180)); + frame(&mut app, Default::default()); + confirm(&mut app).unwrap(); + assert_ne!(app.session.doc().unwrap().doc, before); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); +} + #[test] fn dialogs_are_as_tall_as_their_content() { // The save prompt, shown after a taller dialog, is as tall as its text and buttons: no empty @@ -414,6 +434,38 @@ fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() { assert_eq!(node.appearance.effects.last().map(|e| e.params["scaleH"].clone()), Some(json!(50.0))); } +/// #1000: a count typed in Object › Create Object Mosaic… reaches the command (a whole number typed +/// in a command's dialog is an integer), so the mosaic has that many columns, not the 10 × 10 grid. +#[test] +fn object_mosaic_makes_the_grid_typed_in_its_dialog() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap(); + app.run("object.rasterize", json!({"ppi": 72})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + }; + let entry = crate::menus::menu_entries(&app).into_iter().find(|e| e.label == "Create Object Mosaic…").expect("menu item"); + let (cmd, params) = crate::menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params); + crate::menus::invoke(&mut app, &cmd, params); + // Its first field, Columns, is focused: type 3 over it and press Enter. + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("3".into())]); + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + frame(&mut app, vec![enter]); + assert!(app.ui.dialog.is_none()); + let mut tiles = None; + app.session.doc().unwrap().doc.walk(|n| { + if n.name.as_deref() == Some("Object Mosaic") { + tiles = n.children().map(|c| c.len()); + } + }); + assert_eq!(tiles, Some(3 * 10)); +} + /// #793: Artboard Options lists its fields as Name, Width, Height, not by key. #[test] fn artboard_options_lists_name_then_width_and_height() { diff --git a/crates/ui-egui/src/dialogs/tests_revolve.rs b/crates/ui-egui/src/dialogs/tests_revolve.rs new file mode 100644 index 000000000..554a4fe5b --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_revolve.rs @@ -0,0 +1,201 @@ +//! Real pointer interaction with Revolve's compact controls. +use super::*; +use crate::{dialogs, theme}; +use egui::{Event, PointerButton, Rect, Vec2, pos2}; +use vectorcraft_engine::Session; + +struct Harness { + app: VectorcraftApp, + ctx: egui::Context, + screen: Vec2, + texts: Vec<(String, Rect)>, +} + +impl Harness { + fn new(screen: Vec2) -> Self { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":300,"height":300})).unwrap(); + app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap(); + app.run("paint.setFill", json!({"color":"#338ad6"})).unwrap(); + app.run("effect.dialog", json!({"effect":REVOLVE,"item":null})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + Self { app, ctx, screen, texts: vec![] } + } + + fn frame(&mut self, events: Vec) { + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, self.screen)), events, ..Default::default() }; + let mut out = self.ctx.run_ui(input, |ui| dialogs::show(&mut self.app, ui.ctx())); + out.textures_delta.clear(); + fn collect(shape: &egui::Shape, clip: Rect, texts: &mut Vec<(String, Rect)>) { + match shape { + egui::Shape::Text(t) if clip.contains_rect(t.visual_bounding_rect()) => { + texts.push((t.galley.text().to_string(), t.visual_bounding_rect())) + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, clip, texts)), + _ => {} + } + } + self.texts.clear(); + out.shapes.iter().for_each(|s| collect(&s.shape, s.clip_rect, &mut self.texts)); + } + + fn settle(&mut self) { + for _ in 0..5 { + self.frame(vec![]); + } + } + + fn rect(&self, text: &str) -> Rect { + self.texts.iter().find(|(s, _)| s == text).map(|(_, r)| *r).unwrap_or_else(|| panic!("missing text {text:?}")) + } + + fn response(&self, id: impl std::hash::Hash + std::fmt::Debug) -> Rect { + self.ctx.read_response(Id::new(id)).unwrap().rect + } + + fn click(&mut self, p: Pos2) { + self.frame(vec![Event::PointerMoved(p), button(p, true)]); + self.frame(vec![button(p, false)]); + self.settle(); + } + + fn drag(&mut self, p: Pos2, delta: Vec2) { + self.frame(vec![Event::PointerMoved(p), button(p, true)]); + for i in 1..=5 { + self.frame(vec![Event::PointerMoved(p + delta * (i as f32 / 5.0))]); + } + self.frame(vec![button(p + delta, false)]); + self.settle(); + } + + fn value(&self, key: &str) -> f64 { + self.app.ui.dialog.as_ref().unwrap().f64(key, 0.0) + } +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +#[test] +fn values_labels_sliders_and_reset_dots_change_the_live_effect_in_one_undo_step() { + let mut h = Harness::new(vec2(1280.0, 900.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + assert!(!h.rect("Advanced").is_negative(), "Advanced is visible without scrolling in a normal window"); + let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); + assert!(panel.width() < 420.0 && panel.height() < 540.0, "compact controls: {panel:?}"); + let label = h.rect("Rotation X"); + h.drag(pos2(label.left() + LABEL_W + 6.0 + VALUE_W / 2.0, label.center().y), vec2(40.0, 0.0)); + assert!(h.value("rotationX") > 0.0, "the value itself drags"); + let label = h.rect("Rotation Y"); + h.drag(label.center(), vec2(40.0, 0.0)); + assert!(h.value("rotationY") > 0.0, "the label scrubs"); + let label = h.rect("Revolve angle"); + h.click(pos2(label.left() + LABEL_W + VALUE_W + 24.0, label.center().y)); + assert!(h.value("angle") < 180.0, "the slider changes the sweep"); + h.click(h.response(("revolve-reset", "rotationX")).center()); + assert_eq!(h.value("rotationX"), 0.0); + assert!(h.value("angle") < 180.0, "a dot resets only its own setting"); + let node = h.app.session.doc().unwrap().doc.node(h.app.session.doc().unwrap().selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["angle"], json!(h.value("angle"))); + h.click(h.response("revolve-reset-all").center()); + let defaults = vectorcraft_effects::default_params(REVOLVE).unwrap(); + let dialog = h.app.ui.dialog.as_ref().unwrap(); + for (key, value) in defaults.as_object().unwrap() { + assert_eq!(dialog.fields.get(key), Some(value), "reset all: {key}"); + } + dialogs::confirm(&mut h.app).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + +#[test] +fn preview_off_changes_fields_without_changing_the_document() { + let mut h = Harness::new(vec2(1280.0, 900.0)); + h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + h.drag(h.rect("Rotation Y").center(), vec2(40.0, 0.0)); + assert!(h.value("rotationY") > 0.0); + assert_eq!(h.app.session.doc().unwrap().doc, before); + dialogs::cancel(&mut h.app); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + +#[test] +fn command_driven_fields_preview_and_small_windows_keep_the_buttons_reachable() { + let mut h = Harness::new(vec2(640.0, 480.0)); + h.settle(); + let dialog = h.app.ui.dialog.as_mut().unwrap(); + dialog.fields.insert("angle".into(), json!(180)); + h.settle(); + let state = h.app.session.doc().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["angle"], json!(180)); + let screen = Rect::from_min_size(Pos2::ZERO, h.screen); + assert!(screen.contains_rect(h.rect("OK")) && screen.contains_rect(h.rect("Cancel"))); + dialogs::cancel(&mut h.app); + assert!(!h.app.session.in_interaction()); +} + +#[test] +fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() { + let mut h = Harness::new(vec2(1280.0, 900.0)); + h.settle(); + h.click(h.rect("Lighting").center()); + assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "light"); + for label in ["Shading", "Direction", "Elevation", "Intensity", "Ambient"] { + assert!(Rect::from_min_size(Pos2::ZERO, h.screen).contains_rect(h.rect(label))); + } + let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); + assert!(panel.width() < 420.0 && panel.height() < 460.0, "compact lighting controls: {panel:?}"); + h.drag(h.rect("Direction").center(), vec2(24.0, 0.0)); + assert_ne!(h.value("lightAzimuth"), -45.0); + h.click(h.response(("revolve-reset", "lightAzimuth")).center()); + assert_eq!(h.value("lightAzimuth"), -45.0); + h.click(h.rect("Rotation").center()); + assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "rotation"); + assert!(!h.rect("Rotation X").is_negative()); +} + +#[test] +fn advanced_visibility_option_is_saved_resettable_and_leaves_preview_geometry_complete() { + let mut h = Harness::new(vec2(1280.0, 1000.0)); + h.settle(); + h.click(h.rect("Advanced").center()); + let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); + h.frame(vec![ + Event::PointerMoved(panel.center()), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -220.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }, + ]); + h.settle(); + let before = vectorcraft_effects::revolve_art({ + let state = h.app.session.doc().unwrap(); + state.doc.node(state.selection.objects[0]).unwrap() + }) + .unwrap(); + let checkbox = h.rect("Keep visible surfaces only when expanding"); + h.click(checkbox.center()); + assert!(!h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly")); + let state = h.app.session.doc().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["expandVisibleOnly"], json!(false)); + assert_eq!(vectorcraft_effects::revolve_art(node), Some(before)); + h.click(h.response(("revolve-reset", "expandVisibleOnly")).center()); + assert!(h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly")); + h.click(h.rect("Keep visible surfaces only when expanding").center()); + h.click(h.response("revolve-reset-all").center()); + assert!(h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly")); + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let loaded = vectorcraft_format::load(&vectorcraft_format::save(&state.doc, false)).unwrap(); + assert_eq!(loaded.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true)); +} diff --git a/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs new file mode 100644 index 000000000..02c3fa3e4 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs @@ -0,0 +1,250 @@ +//! Pointer gestures exercise the real artboard overlay and command preview, including rollback. +use super::*; +use crate::{dialogs, theme}; +use egui::{Event, Modifiers, PointerButton}; +use vectorcraft_engine::Session; + +struct Harness { + app: VectorcraftApp, + ctx: egui::Context, +} + +impl Harness { + fn new() -> Self { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":300,"height":300})).unwrap(); + app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap(); + app.run("paint.setFill", json!({"color":"#338ad6"})).unwrap(); + app.run("effect.dialog", json!({"effect":REVOLVE,"item":null})).unwrap(); + app.canvas_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0))); + let v = app.view_mut().unwrap(); + v.center = Point::new(270.0, 150.0); + v.zoom = 2.0; + v.fitted = true; + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let mut h = Self { app, ctx }; + h.settle(); + h + } + + fn frame(&mut self, events: Vec, modifiers: Modifiers) { + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0))), events, ..Default::default() }; + let mut out = self.ctx.run_ui(input, |ui| { + crate::shortcuts::handle(&mut self.app, ui.ctx()); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::show(&mut self.app, ui)); + dialogs::show(&mut self.app, ui.ctx()); + }); + out.textures_delta.clear(); + } + + fn settle(&mut self) { + for _ in 0..5 { + self.frame(vec![], Modifiers::NONE); + } + } + + fn geometry(&self) -> Geometry { + Geometry::new(&self.app, self.app.ui.dialog.as_ref().unwrap()).unwrap() + } + + fn value(&self, key: &str) -> f64 { + self.app.ui.dialog.as_ref().unwrap().f64(key, 0.0) + } + + fn start(&mut self, p: Pos2, modifiers: Modifiers) { + self.frame(vec![Event::PointerMoved(p)], modifiers); + self.frame(vec![button(p, true, modifiers)], modifiers); + } + + fn release(&mut self, p: Pos2, modifiers: Modifiers) { + self.frame(vec![button(p, false, modifiers)], modifiers); + self.settle(); + } + + fn ring_drag(&mut self, axis: usize, modifiers: Modifiers) { + let g = self.geometry(); + let ring = g.rings[axis]; + // Choose a visible point belonging only to this ring, away from crossings. + let phase = (0..SAMPLES) + .map(|i| i as f32 * std::f32::consts::TAU / SAMPLES as f32) + .find(|a| g.hit(g.center + ring.point(*a)).is_some_and(|h| h.0 == axis) && ring.tangent(*a).length() > 40.0) + .unwrap(); + let from = g.center + ring.point(phase); + self.start(from, modifiers); + let mut p = from; + for i in 1..=8 { + p = g.center + ring.point(phase + i as f32 * 0.045); + self.frame(vec![Event::PointerMoved(p)], modifiers); + } + self.release(p, modifiers); + } +} + +fn button(pos: Pos2, pressed: bool, modifiers: Modifiers) -> Event { + Event::PointerButton { pos, pressed, button: PointerButton::Primary, modifiers } +} +fn escape() -> Event { + Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } +} + +#[test] +fn each_ring_changes_only_its_rotation_field_and_cancel_restores_the_source() { + for (axis, key) in KEYS.iter().enumerate() { + let mut h = Harness::new(); + let before = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone(); + let original = KEYS.map(|key| h.value(key)); + h.ring_drag(axis, Modifiers::NONE); + for (k, key) in KEYS.iter().enumerate() { + if k == axis { + assert!(h.value(key) > original[k] + 10.0, "axis {axis}, {}", h.value(key)); + } else { + assert_eq!(h.value(key), original[k]); + } + } + let state = h.app.session.active().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.path_data(), before.node(node.id).unwrap().path_data(), "ring drags must not move source anchors"); + assert_eq!(node.appearance.effects[0].params[*key], json!(h.value(key))); + dialogs::cancel(&mut h.app); + assert_eq!(h.app.session.doc().unwrap().doc, before); + } +} + +#[test] +fn ring_drag_snaps_and_confirm_is_one_undo_step() { + let mut h = Harness::new(); + let before = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone(); + h.ring_drag(2, Modifiers::SHIFT); + assert!(h.value("rotationZ") > 0.0); + assert_eq!(h.value("rotationZ") % 15.0, 0.0); + dialogs::confirm(&mut h.app).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + +#[test] +fn free_orbit_and_light_drag_update_the_canvas_and_escape_restores_only_the_gesture() { + let mut h = Harness::new(); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + let center = h.geometry().center; + h.start(center, Modifiers::NONE); + let p = center + vec2(50.0, 30.0); + h.frame(vec![Event::PointerMoved(p)], Modifiers::NONE); + assert_ne!(h.value("rotationX"), before["rotationX"].as_f64().unwrap()); + assert_ne!(h.value("rotationY"), before["rotationY"].as_f64().unwrap()); + h.frame(vec![escape()], Modifiers::NONE); + h.release(p, Modifiers::NONE); + assert!(h.app.ui.dialog.is_some()); + assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); + let state = h.app.session.active().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params["rotationX"], before["rotationX"]); + assert_eq!(node.appearance.effects[0].params["rotationY"], before["rotationY"]); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light")); + h.settle(); + let light = light_position(h.geometry(), revolve_options(&before)); + h.start(light, Modifiers::NONE); + let p = light + vec2(20.0, 25.0); + h.frame(vec![Event::PointerMoved(p)], Modifiers::NONE); + h.release(p, Modifiers::NONE); + assert_ne!(h.value("lightAzimuth"), -45.0); + assert_ne!(h.value("lightElevation"), 45.0); + h.frame(vec![escape()], Modifiers::NONE); + assert!(h.app.ui.dialog.is_none() && !h.app.session.in_interaction()); +} + +#[test] +fn preview_off_has_no_gizmo_and_edge_on_rings_remain_finite() { + let mut h = Harness::new(); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("rotationX".into(), json!(0.0)); + d.fields.insert("rotationY".into(), json!(0.0)); + d.fields.insert("rotationZ".into(), json!(0.0)); + h.settle(); + h.ring_drag(0, Modifiers::NONE); + assert!(h.value("rotationX").is_finite() && h.value("rotationX").abs() > 1.0); + h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false)); + h.settle(); + assert!(!h.app.session.in_interaction()); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + h.ring_drag(2, Modifiers::NONE); + assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); +} + +#[test] +fn light_rings_adjust_only_their_angle_and_snap_without_rotating_the_object() { + for (axis, key) in ["lightAzimuth", "lightElevation"].iter().enumerate() { + let mut h = Harness::new(); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light")); + h.settle(); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + let from = h.geometry().light_labels()[axis].center(); + h.start(from, Modifiers::SHIFT); + let delta = if axis == 0 { vec2(68.0, 0.0) } else { vec2(0.0, -43.0) }; + h.frame(vec![Event::PointerMoved(from + delta)], Modifiers::SHIFT); + h.release(from + delta, Modifiers::SHIFT); + assert_ne!(h.value(key), before[*key].as_f64().unwrap()); + assert_eq!(h.value(key) % 15.0, 0.0); + for unchanged in ["rotationX", "rotationY", "rotationZ", "lightIntensity", "ambient"] { + assert_eq!(h.value(unchanged), before[unchanged].as_f64().unwrap()); + } + let other = if axis == 0 { "lightElevation" } else { "lightAzimuth" }; + assert_eq!(h.value(other), before[other].as_f64().unwrap()); + let state = h.app.session.active().unwrap(); + let node = state.doc.node(state.selection.objects[0]).unwrap(); + assert_eq!(node.appearance.effects[0].params[*key], json!(h.value(key))); + } +} + +#[test] +fn light_handle_preserves_the_back_side_and_escape_cancel_and_undo_restore_changes() { + let mut h = Harness::new(); + let original = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone(); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("__gizmo".into(), json!("light")); + d.fields.insert("lightAzimuth".into(), json!(135.0)); + h.settle(); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + let light = light_position(h.geometry(), revolve_options(&before)); + h.start(light, Modifiers::NONE); + let to = light + vec2(-15.0, 12.0); + h.frame(vec![Event::PointerMoved(to)], Modifiers::NONE); + assert!(revolve_options(&super::super::form::params(h.app.ui.dialog.as_ref().unwrap())).light_direction()[2] < 0.0); + assert_ne!(h.value("lightElevation"), 45.0); + h.frame(vec![escape()], Modifiers::NONE); + h.release(to, Modifiers::NONE); + assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); + let state = h.app.session.active().unwrap(); + assert_eq!(state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["lightAzimuth"], before["lightAzimuth"]); + // A second gesture is kept by OK, and the whole dialog is one undo step. + let from = h.geometry().light_labels()[0].center(); + h.start(from, Modifiers::NONE); + h.frame(vec![Event::PointerMoved(from + vec2(35.0, 0.0))], Modifiers::NONE); + h.release(from + vec2(35.0, 0.0), Modifiers::NONE); + dialogs::confirm(&mut h.app).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, original); +} + +#[test] +fn light_elevation_is_bounded_and_shading_off_disables_the_light_gizmo() { + let mut h = Harness::new(); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light")); + h.settle(); + let from = h.geometry().light_labels()[1].center(); + h.start(from, Modifiers::NONE); + h.frame(vec![Event::PointerMoved(from - vec2(0.0, 400.0))], Modifiers::NONE); + h.release(from - vec2(0.0, 400.0), Modifiers::NONE); + assert_eq!(h.value("lightElevation"), 90.0); + h.app.ui.dialog.as_mut().unwrap().fields.insert("shade".into(), json!(false)); + h.settle(); + let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap()); + let from = h.geometry().light_labels()[0].center(); + h.start(from, Modifiers::NONE); + h.frame(vec![Event::PointerMoved(from + vec2(40.0, 0.0))], Modifiers::NONE); + h.release(from + vec2(40.0, 0.0), Modifiers::NONE); + assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index bdff9ef0d..62cf2df93 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1,5 +1,6 @@ //! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column -//! whose panels pop out to the left. The double arrow at the top of the dock collapses the tabbed +//! whose panels pop out to the left, and left of it the floating sets docked as columns +//! ([`floating::docked_columns`]). The double arrow at the top of the dock collapses the tabbed //! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column //! then expands it again. @@ -23,6 +24,12 @@ fn column_left_id() -> egui::Id { egui::Id::new("dock-icon-column-left") } +/// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's +/// (egui temp memory, one frame old). Popped-out panels open left of it. +pub(crate) fn dock_left_id() -> egui::Id { + egui::Id::new("dock-left") +} + /// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while /// one of its panels is popped out shows that panel's tab instead. pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) { @@ -64,6 +71,7 @@ fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &s // Its own id: the icons below it move up when it floats, and mustn't take over its drag. let (_, rect) = ui.allocate_space(vec2(30.0, 30.0)); let resp = widgets::paint_icon_button(ui, ui.interact(rect, ui.id().with(("panel-icon", id)), Sense::click_and_drag()), icon, label, open); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), open, tl!(label))); if resp.clicked() { app.ui.open_panel = if open { None } else { Some(id.to_string()) }; } @@ -149,7 +157,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); let column = column.response.rect; let dock = group.map_or(column, |g| g.union(column)); + // Floating sets docked as columns, left of the icon column. + let left = floating::docked_columns(app, ui).iter().map(|r| r.left()).fold(column.left(), f32::min); ui.ctx().data_mut(|d| { + d.insert_temp(dock_left_id(), left); d.insert_temp(column_left_id(), column.left()); d.insert_temp(floating::dock_rect_id(), dock); d.insert_temp(floating::icons_rect_id(), column); @@ -313,7 +324,9 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { } else { screen.right() }; - let right = column - 6.0; + let left = ctx.data(|d| d.get_temp::(dock_left_id())).filter(|x| x.is_finite() && *x > screen.left() && *x <= column).unwrap_or(column); + let right = if app.ui.dock && app.ui.screen_mode < 3 { left } else { column }; + let right = right - 6.0; // The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one // that keeps the flyout on screen. let width = panel_width(id); diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index ce57a999a..cd5ec9f6d 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -9,9 +9,18 @@ //! × of a group puts its panels back in the dock too. The Tools panel floats the same way by its //! title bar ([`crate::toolbar`]) and docks again on the window's left edge. //! -//! `window.panel.float` / `window.panel.dock` do the same for agents. The groups and the Tools -//! panel's position are kept with the preferences and in user workspaces, and a position saved on -//! a bigger window is clamped into this one when drawn. +//! Dropped on the upper or lower half of another group (a line shows where), a group joins it in +//! a set right above or below it: the groups stack top to bottom, each showing one of its panels, +//! and move together by the top group's title bar, whose × docks them all; dropped on its tabs (lit +//! up), its panels join that group as tabs. A group dragged by its tab strip (a lone panel's +//! tab, or the strip right of the tabs) leaves its set and floats on its own. A double-click on a +//! tab collapses a group to its tabs, and another expands it. A dock column whose panels don't fit +//! the window scrolls. +//! +//! `window.panel.float` (`onto`, `below`, `above`, `collapsed`) / `window.panel.dock` do the same +//! for agents. The groups, their sets and the Tools panel's position are kept with the preferences +//! and in user workspaces, and a position saved on a bigger window is clamped into this one when +//! drawn. use egui::{CornerRadius, Id, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2}; use serde_json::{Value, json}; @@ -24,6 +33,12 @@ use crate::{VectorcraftApp, dock, icons, panels}; pub const TITLE: f32 = 14.0; /// Height of a floating group's tab strip. pub const STRIP: f32 = 26.0; +/// Height of the bar between two groups of a set. +pub const GAP: f32 = 3.0; +/// The least height of an open panel in a dock column: more panels than fit scroll. +const MIN_TALL: f32 = 160.0; +/// How far from a group's top or bottom edge a drop stacks it above or below the group. +const EDGE: f32 = 7.0; /// Where `window.panel.float` floats a group given no position; each further group 24 points /// lower right. const FLOAT_AT: [f32; 2] = [420.0, 120.0]; @@ -59,12 +74,27 @@ pub(crate) enum Drop { Dock, /// Stacked with the floating group holding this panel. Stack(&'static str), + /// In a set with the floating group holding this panel, below it. + Below(&'static str), + /// In a set with the floating group holding this panel, above it (the top group of its set). + Above(&'static str), + /// In the dock as a new column at this place (1: next to the icon column, counting left). + Column(u32), } fn move_id() -> Id { Id::new("floating-move") } +fn suppressed_id() -> Id { + move_id().with("suppressed-until-primary-press") +} + +fn gesture_allowed(ctx: &egui::Context) -> bool { + ctx.input(|input| input.focused && !input.key_pressed(egui::Key::Escape)) + && !ctx.data(|data| data.get_temp::(suppressed_id()).unwrap_or(false)) +} + /// The dock (tabbed group and icon column) as last drawn: a group dropped there docks. pub(crate) fn dock_rect_id() -> Id { Id::new("floating-dock-rect") @@ -81,11 +111,17 @@ pub(crate) fn tools_zone_id() -> Id { Id::new("floating-tools-zone") } -/// The area of the floating group whose first panel is `first`. +/// The area of the floating group whose first panel is `first` (of the set it heads). pub fn area_id(first: &str) -> Id { Id::new(("floating-panels", first)) } +/// The floating group whose first panel is `first`, as last drawn (egui temp memory): in a set, +/// from the bar above its tabs to its panel's bottom. +pub(crate) fn group_rect_id(first: &str) -> Id { + Id::new(("floating-group-rect", first)) +} + /// Panel `id` (`window.panel`) as a static string and its English label, if it is a panel. fn panel(id: &str) -> Option<(&'static str, &'static str)> { all_panels().find(|(p, _)| *p == id) @@ -96,6 +132,72 @@ pub fn group_of(ui: &UiState, id: &str) -> Option { ui.floating_panels.iter().position(|g| g.panels.iter().any(|p| p == id)) } +/// The floating groups drawn with group `gi`, top to bottom: the groups of its set, or it alone. +pub fn set_of(ui: &UiState, gi: usize) -> Vec { + match ui.floating_panels.get(gi).and_then(|g| g.column) { + Some(c) => ui.floating_panels.iter().enumerate().filter(|(_, g)| g.column == Some(c)).map(|(i, _)| i).collect(), + None => vec![gi], + } +} + +/// Group `gi` heads its set (or floats alone): it draws the set's title bar. +fn leads(ui: &UiState, gi: usize) -> bool { + set_of(ui, gi).first() == Some(&gi) +} + +/// Move the set of group `gi` to `pos`. +fn set_pos(ui: &mut UiState, gi: usize, pos: [f32; 2]) { + for i in set_of(ui, gi) { + if let Some(g) = ui.floating_panels.get_mut(i) { + g.pos = pos; + } + } +} + +/// The panels of the groups `set`, in order. +fn ids_in(ui: &UiState, set: &[usize]) -> Vec<&'static str> { + set.iter().filter_map(|&i| ui.floating_panels.get(i)).flat_map(ids_of).collect() +} + +/// The panel shown by group `gi`. +fn shown(ui: &UiState, gi: usize) -> Option<&'static str> { + let g = ui.floating_panels.get(gi)?; + let ids = ids_of(g); + ids.get(g.active).or(ids.first()).copied() +} + +/// Put the groups `moved` in a set with the group holding `onto` (not one of them), right below or +/// right above it, at its position. +pub fn attach(ui: &mut UiState, moved: &[usize], onto: &str, below: bool) { + let Some(target) = group_of(ui, onto).filter(|t| !moved.contains(t)) else { return }; + let column = match ui.floating_panels.get(target).and_then(|g| g.column) { + Some(c) => c, + None => ui.floating_panels.iter().filter_map(|g| g.column).max().map_or(1, |c| c.saturating_add(1)), + }; + let Some((pos, docked)) = ui.floating_panels.get(target).map(|g| (g.pos, g.docked)) else { return }; + let (mut taken, mut kept) = (vec![], vec![]); + for (i, mut g) in ui.floating_panels.drain(..).enumerate() { + if i == target { + g.column = Some(column); + } + if moved.contains(&i) { + g.column = Some(column); + g.pos = pos; + g.docked = docked; + taken.push(g); + } else { + kept.push(g); + } + } + let at = kept.iter().position(|g| g.panels.iter().any(|p| p == onto)).map_or(kept.len(), |i| if below { i + 1 } else { i }); + kept.splice(at..at, taken); + ui.floating_panels = kept; + FloatingPanels::tidy(&mut ui.floating_panels); + if let Some(tab) = shown_tab(ui) { + ui.dock_tab = tab; + } +} + /// The panels of group `g`, as static ids. fn ids_of(g: &FloatingPanels) -> Vec<&'static str> { g.panels.iter().filter_map(|p| panel(p)).map(|(id, _)| id).collect() @@ -119,6 +221,7 @@ fn take(ui: &mut UiState, ids: &[&str]) { g.active = active.and_then(|a| g.panels.iter().position(|p| *p == a)).unwrap_or(0); !g.panels.is_empty() }); + FloatingPanels::tidy(&mut ui.floating_panels); } /// Float `ids` (out of the dock or of the group they were in) as a new group with its top-left @@ -130,7 +233,7 @@ pub fn float(ui: &mut UiState, ids: &[&str], active: &str, pos: Pos2) { } let panels: Vec = ids.iter().map(|id| id.to_string()).collect(); let active = panels.iter().position(|p| p == active).unwrap_or(0); - ui.floating_panels.push(FloatingPanels { panels, active, pos: [pos.x, pos.y], width: None }); + ui.floating_panels.push(FloatingPanels { panels, active, pos: [pos.x, pos.y], ..Default::default() }); // The tabbed group shows another of its tabs when the one it showed floats. if let Some(tab) = shown_tab(ui) { ui.dock_tab = tab; @@ -174,6 +277,9 @@ pub fn clamp(pos: [f32; 2], size: Vec2, screen: Rect) -> Pos2 { /// Start moving `what` with the pointer, held `grab` from its top-left corner. fn start(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, grab: Vec2) { + if !gesture_allowed(ctx) { + return; + } let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return }; ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from, original: app.ui.clone() })); } @@ -190,7 +296,7 @@ pub(crate) fn strip_grab(ctx: &egui::Context, left: f32) -> Vec2 { pub(crate) fn tear(app: &mut VectorcraftApp, ctx: &egui::Context, ids: &[&'static str], active: &'static str, grab: Vec2) { // A restored popout may retain its drag response in the cancellation frame. Do not // immediately tear it out again after track restored the gesture's original layout. - if ctx.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) { + if !gesture_allowed(ctx) { return; } let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; @@ -212,6 +318,9 @@ pub(crate) fn tear(app: &mut VectorcraftApp, ctx: &egui::Context, ids: &[&'stati /// The Tools panel's title bar was pressed (`title`, the panel's bounds `bounds`): docked, dragging /// it out of the panel floats the panel under the pointer; floating at `floating`, a drag moves it. pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: &egui::Response, bounds: Rect, floating: Option) { + if !gesture_allowed(ctx) { + return; + } let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; match floating { Some(pos) if title.drag_started() => start(app, ctx, Moving::Tools, at - pos), @@ -219,8 +328,8 @@ pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: & // Held where the press grabbed the title bar. let grab = ctx.input(|i| i.pointer.press_origin()).map_or(vec2(10.0, 7.0), |p| p - bounds.min); let p = at - grab; - app.ui.toolbar_pos = Some([p.x, p.y]); start(app, ctx, Moving::Tools, grab); + app.ui.toolbar_pos = Some([p.x, p.y]); } _ => {} } @@ -231,15 +340,87 @@ pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Movin let zone = |id: Id| ctx.data(|d| d.get_temp::(id)).filter(|r| r.contains(at)); let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) }; let own = group_of(&app.ui, id); - // Another group's title bar and tabs stack with it. + let moving = own.map(|i| set_of(&app.ui, i)).unwrap_or_default(); + // The left edge of the icon column or of a dock column docks the moved groups as a new column + // there. + if app.ui.dock && app.ui.screen_mode < 3 { + let icons = ctx.data(|d| d.get_temp::(icons_rect_id())); + let columns = ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default(); + for (k, r) in icons.into_iter().chain(columns).enumerate() { + if Rect::from_x_y_ranges((r.left() - EDGE - 3.0)..=(r.left() + EDGE - 1.0), r.y_range()).contains(at) { + let line = Rect::from_x_y_ranges((r.left() - 2.0)..=(r.left() + 2.0), r.y_range()); + return Some((Drop::Column(u32::try_from(k + 1).unwrap_or(u32::MAX)), line)); + } + } + } + // Another group (floating or docked): its tabs (and a set's title bar) stack the moved groups + // with it as tabs; the upper half of the group (and just above a set) puts them in its set + // right above it, the lower half (and just below it) right below it. + let columns = ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default(); for (i, g) in app.ui.floating_panels.iter().enumerate() { - let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| Some(i) != own) else { continue }; - let area = - if app.ui.docking.is_some() { Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())) } else { area_id(first) }; - let head = ctx.memory(|m| m.area_rect(area)).map(|r| Rect::from_min_size(r.min, vec2(r.width(), 1.0 + TITLE + STRIP))); - if let Some(head) = head.filter(|h| h.contains(at)) { + let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue }; + let Some(mut r) = ctx + .data(|d| d.get_temp::(group_rect_id(first))) + .or_else(|| ctx.memory(|m| m.area_rect(Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))))) + else { + continue; + }; + // A visible window owns hits inside its body. Old rectangles and lower windows + // must not capture a drop through another panel or popup; edge insertion remains + // available just outside the window. Dock columns reserve their own panel space. + if g.docked.is_none() && r.contains(at) { + let area = if app.ui.docking.is_some() && !crate::panel_docking::decorated(g) { + Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())) + } else { + let lead = set_of(&app.ui, i).first().and_then(|&index| app.ui.floating_panels.get(index)).and_then(|group| group.panels.first()); + area_id(lead.map_or(first, String::as_str)) + }; + let moving_areas: Vec = moving + .iter() + .filter_map(|&index| app.ui.floating_panels.get(index).and_then(|group| group.panels.first())) + .flat_map(|panel| [area_id(panel), Id::new("vectorcraft-panel-docking").with(("floating", panel))]) + .collect(); + let owner = ctx.memory(|memory| { + let layers: Vec<_> = memory.layer_ids().collect(); + layers.into_iter().rev().find(|layer| { + !moving_areas.contains(&layer.id) + && memory.areas().is_visible(layer) + && memory.area_rect(layer.id).is_some_and(|rect| rect.contains(at)) + }) + }); + if owner.is_some_and(|layer| layer.id != area) { + continue; + } + } + // In a dock column that scrolls, only the part in view: a group scrolled away takes nothing. + if let Some(col) = g.docked.and_then(|d| columns.get(usize::try_from(d).ok()?.checked_sub(1)?)) { + r = r.intersect(*col); + if r.height() <= 0.0 || r.width() <= 0.0 { + continue; + } + } + let line = |y: f32| Rect::from_x_y_ranges(r.x_range(), (y - 2.0)..=(y + 2.0)); + let lead = leads(&app.ui, i); + let head = Rect::from_min_size(r.min, vec2(r.width(), if lead { 1.0 + TITLE + STRIP } else { GAP + STRIP })); + if head.contains(at) { return Some((Drop::Stack(first), head)); } + let above = if lead { EDGE } else { 0.0 }; + if Rect::from_x_y_ranges(r.x_range(), (r.top() - above)..=(r.bottom() + EDGE)).contains(at) { + let mid = (head.bottom() + r.bottom()) / 2.0; + return Some(if at.y < mid { (Drop::Above(first), line(r.top())) } else { (Drop::Below(first), line(r.bottom())) }); + } + } + // A dock column's room under its groups puts them at its bottom. + for ((_, set), col) in docked_sets(&app.ui).into_iter().zip(columns) { + if !col.contains(at) || set.iter().any(|i| moving.contains(i)) { + continue; + } + let Some(last) = set.last().and_then(|&i| app.ui.floating_panels.get(i)).and_then(|g| g.panels.first()).and_then(|p| panel(p)) else { + continue; + }; + let y = ctx.data(|d| d.get_temp::(group_rect_id(last.0))).map_or(col.bottom(), |r| r.bottom()).min(col.bottom() - 2.0); + return Some((Drop::Below(last.0), Rect::from_x_y_ranges(col.x_range(), (y - 2.0)..=(y + 2.0)))); } let dock = zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3)?; // Panels that aren't tabs of the tabbed group (or all of them, with it collapsed) go back to @@ -257,12 +438,41 @@ pub(crate) fn highlight(ctx: &egui::Context, zone: Rect) { painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); } +/// Light up the tabs a drop stacks the moved panels with, over the floating and docked groups. +fn tab_zone(ctx: &egui::Context, tabs: Rect) { + let t = Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("floating-drop-tabs"))); + painter.rect(tabs.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.25), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); +} + +/// Show where a drop puts the moved groups in a set: a line over the floating groups. +fn insertion_line(ctx: &egui::Context, line: Rect) { + let t = Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("floating-drop-line"))); + painter.rect_filled(line, CornerRadius::same(2), t.accent); +} + +pub(crate) fn preview(ctx: &egui::Context, target: Drop, rect: Rect) { + match target { + Drop::Below(_) | Drop::Above(_) | Drop::Column(_) => insertion_line(ctx, rect), + Drop::Stack(_) => tab_zone(ctx, rect), + Drop::Dock => highlight(ctx, rect), + } +} + /// Carry a move on (at the start of a frame, before the panels are drawn): the group or Tools panel /// follows the pointer and the zone it would drop on lights up; the release drops it there. pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { + if ctx.input(|input| input.pointer.primary_pressed()) { + ctx.data_mut(|data| data.remove::(suppressed_id())); + } + let before = app.ui.clone(); let Some(m) = ctx.data(|d| d.get_temp::(move_id())) else { return }; if m.original.docking_generation != app.ui.docking_generation { - ctx.data_mut(|data| data.remove::(move_id())); + ctx.data_mut(|data| { + data.remove::(move_id()); + data.insert_temp(suppressed_id(), true); + }); return; } if ctx.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) { @@ -273,7 +483,10 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { app.ui.open_panel = m.original.open_panel; app.ui.dock_tab = m.original.dock_tab; app.ui.toolbar_pos = m.original.toolbar_pos; - ctx.data_mut(|data| data.remove::(move_id())); + ctx.data_mut(|data| { + data.remove::(move_id()); + data.insert_temp(suppressed_id(), true); + }); return; } let at = ctx.input(|i| i.pointer.interact_pos()); @@ -284,22 +497,23 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { && p.y.is_finite() { match m.what { - Moving::Panel(id) if app.ui.docking.is_some() => { - if let Err(error) = app.run("window.panel.float", json!({"panel":id,"group":true,"x":p.x,"y":p.y})) { - app.ui.status = error; - } - } Moving::Panel(id) => { - if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) { - g.pos = [p.x, p.y]; + if let Some(gi) = group_of(&app.ui, id) { + set_pos(&mut app.ui, gi, [p.x, p.y]); } } Moving::Tools if app.ui.toolbar_pos.is_some() => app.ui.toolbar_pos = Some([p.x, p.y]), Moving::Tools => {} } } - if let Some((_, zone)) = target { - highlight(ctx, zone); + if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) { + app.ui.status = error; + } + match target { + Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line), + Some((Drop::Stack(_), tabs)) => tab_zone(ctx, tabs), + Some((_, zone)) => highlight(ctx, zone), + None => {} } return; } @@ -309,19 +523,11 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { app.ui.toolbar_pos = None; return; }; - if app.ui.docking.is_some() { - let (command, params) = match drop { - Drop::Dock => ("window.panel.dock", json!({"panel":id,"group":true})), - Drop::Stack(onto) => ("window.panel.float", json!({"panel":id,"group":true,"onto":onto})), - }; - if let Err(error) = app.run(command, params) { - app.ui.status = error; - } - return; - } - let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)) else { return }; - let ids = ids_of(g); - let active = ids.get(g.active).copied().unwrap_or(id); + // The moved set: its groups' panels, showing the top group's. + let Some(gi) = group_of(&app.ui, id) else { return }; + let set = set_of(&app.ui, gi); + let ids = ids_in(&app.ui, &set); + let active = set.first().and_then(|&i| shown(&app.ui, i)).unwrap_or(id); match drop { Drop::Dock => { dock(&mut app.ui, &ids, active); @@ -331,139 +537,394 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { } } Drop::Stack(onto) => stack(&mut app.ui, &ids, active, onto), + Drop::Below(onto) => attach(&mut app.ui, &set, onto, true), + Drop::Above(onto) => attach(&mut app.ui, &set, onto, false), + Drop::Column(slot) => dock_column(&mut app.ui, &set, slot), + } + if let Err(error) = crate::panel_docking::reconcile_native(app, before) { + app.ui.status = error; } } -/// What a press on a floating group did. +/// What a press on a floating or docked group did. enum Action { /// Show the group's `k`th tab. Activate(usize, usize), - /// Its × puts the group's panels back in the dock. - Close(usize), - /// Move the group holding this panel, drawn at this corner. + /// A double-click on a tab collapses the group to its tabs, or expands it. + Collapse(usize), + /// Its × puts the panels of the set's groups back in the dock. + Close(Vec), + /// Move the set of the group holding this panel, drawn at this corner. Move(&'static str, Pos2), + /// Take this group out of its set and move it on its own; its tab strip began this far right. + Detach(usize, f32), /// Float this panel out of its group; its tab began this far right. Tear(&'static str, f32), + /// Float the docked column of this group, its top-left corner this far from the pointer. + Undock(usize, Vec2), } -/// The floating groups, each a stack of tabs under a title bar. Hidden with the dock (Tab, -/// Presentation Mode). +/// A floating group as drawn: its index, panels, the one shown and whether it's collapsed. +struct Shown { + gi: usize, + ids: Vec<&'static str>, + active: &'static str, + collapsed: bool, +} + +/// The groups `set` as drawn, top to bottom. +fn shown_groups(ui: &UiState, set: &[usize]) -> Vec { + set.iter() + .filter_map(|&gi| { + let g = ui.floating_panels.get(gi)?; + let ids = ids_of(g); + let active = ids.get(g.active).or(ids.first()).copied()?; + Some(Shown { gi, ids, active, collapsed: g.collapsed }) + }) + .collect() +} + +/// The groups `set` heads are docked as a dock column. +fn docked(ui: &UiState, set: &[usize]) -> bool { + set.first().and_then(|&i| ui.floating_panels.get(i)).is_some_and(|g| g.docked.is_some()) +} + +/// The floating groups, each a stack of tabs, stacked in their sets under a title bar. Hidden with +/// the dock (Tab, Presentation Mode). Sets docked as dock columns are drawn by [`docked_columns`]. pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { - if app.ui.docking.is_some() { - return; - } if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 { + crate::panel_docking::finish_native_drop(app, ctx); return; } - let t = Tokens::get(ctx); - let screen = ctx.content_rect(); - let pointer = ctx.input(|i| i.pointer.interact_pos()); let mut action = None; for gi in 0..app.ui.floating_panels.len() { - let Some(g) = app.ui.floating_panels.get(gi) else { break }; - let ids = ids_of(g); - let Some(&first) = ids.first() else { continue }; - let active = ids.get(g.active).copied().unwrap_or(first); - let given = g.width.map(|w| w.min(screen.width() - 16.0)); - let area = area_id(first); - let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(active), 200.0), |r| r.size()); - let pos = clamp(g.pos, size, screen); - // The tabbed group's panels fill (Layers) or scroll (Properties) to near the window's bottom, - // leaving room for their margins (a group that only just fitted would creep up a little each - // frame). - let tall = (screen.bottom() - pos.y - TITLE - STRIP - 60.0).clamp(120.0, 560.0); - let lone = ids.len() == 1; - egui::Area::new(area).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { - let frame = egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO); - frame.show(ui, |ui| { + if leads(&app.ui, gi) && (app.ui.docking.is_none() || app.ui.floating_panels.get(gi).is_some_and(crate::panel_docking::decorated)) { + let set = set_of(&app.ui, gi); + if !docked(&app.ui, &set) { + show_set(app, ctx, &set, &mut action); + } + } + } + apply(app, ctx, action); + crate::panel_docking::finish_native_drop(app, ctx); +} + +/// Carry out what a press on a floating or docked group did. +fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) { + if action.is_none() { + return; + } + if matches!(action, Some(Action::Move(..) | Action::Detach(..) | Action::Tear(..) | Action::Undock(..))) && !gesture_allowed(ctx) { + return; + } + let before = app.ui.clone(); + match action { + Some(Action::Activate(gi, k)) => { + if let Some(g) = app.ui.floating_panels.get_mut(gi) + && g.active != k + { + g.active = k; + g.collapsed = false; + } + } + Some(Action::Collapse(gi)) => { + if let Some(g) = app.ui.floating_panels.get_mut(gi) { + g.collapsed = !g.collapsed; + } + } + Some(Action::Close(set)) => { + let ids = ids_in(&app.ui, &set); + let active = set.first().and_then(|&i| shown(&app.ui, i)).unwrap_or_default(); + dock(&mut app.ui, &ids, active); + } + Some(Action::Move(id, pos)) => { + // From where it is drawn (a position saved on a bigger window was clamped into this one). + if let Some(gi) = group_of(&app.ui, id) { + set_pos(&mut app.ui, gi, [pos.x, pos.y]); + } + if let Some(at) = ctx.input(|i| i.pointer.interact_pos()) { + start(app, ctx, Moving::Panel(id), at - pos); + } + } + Some(Action::Detach(gi, left)) => detach(app, ctx, gi, left), + Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)), + Some(Action::Undock(gi, grab)) => { + let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + let Some(id) = set_of(&app.ui, gi).first().and_then(|&i| shown(&app.ui, i)) else { return }; + let p = at - grab; + for i in set_of(&app.ui, gi) { + if let Some(g) = app.ui.floating_panels.get_mut(i) { + g.docked = None; + g.pos = [p.x, p.y]; + } + } + FloatingPanels::tidy(&mut app.ui.floating_panels); + start(app, ctx, Moving::Panel(id), grab); + } + None => {} + } + if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) { + app.ui.status = error; + } + ctx.data_mut(|data| { + if let Some(mut drag) = data.get_temp::(move_id()) { + drag.original = before; + data.insert_temp(move_id(), drag); + } + }); +} + +/// Take group `gi` out of its set (or its dock column) under the pointer, its tab strip having +/// begun `left` points from the window's left, and carry on moving it with the pointer. +fn detach(app: &mut VectorcraftApp, ctx: &egui::Context, gi: usize, left: f32) { + let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + let Some(id) = shown(&app.ui, gi) else { return }; + let grab = strip_grab(ctx, left); + let p = at - grab; + if let Some(g) = app.ui.floating_panels.get_mut(gi) { + g.column = None; + g.docked = None; + g.pos = [p.x, p.y]; + } + FloatingPanels::tidy(&mut app.ui.floating_panels); + start(app, ctx, Moving::Panel(id), grab); +} + +/// Put the groups `set` in the dock as a new column at `slot` (1: next to the icon column, counting +/// left), moving the columns from there one further left. +pub fn dock_column(ui: &mut UiState, set: &[usize], slot: u32) { + let slot = slot.max(1); + for (i, g) in ui.floating_panels.iter_mut().enumerate() { + if set.contains(&i) { + g.docked = Some(slot); + } else if let Some(d) = g.docked.filter(|d| *d >= slot) { + g.docked = Some(d.saturating_add(1)); + } + } + FloatingPanels::tidy(&mut ui.floating_panels); + if let Some(tab) = shown_tab(ui) { + ui.dock_tab = tab; + } +} + +/// The dock columns, as last drawn (egui temp memory), from the one next to the icon column +/// leftwards. +pub(crate) fn columns_id() -> Id { + Id::new("dock-columns") +} + +/// The sets docked as columns, by their column number (next to the icon column first). +fn docked_sets(ui: &UiState) -> Vec<(u32, Vec)> { + let mut sets: Vec<(u32, Vec)> = (0..ui.floating_panels.len()) + .filter(|&gi| leads(ui, gi)) + .filter_map(|gi| Some((ui.floating_panels.get(gi)?.docked?, set_of(ui, gi)))) + .collect(); + sets.sort_by_key(|(d, _)| *d); + sets +} + +/// The sets docked as columns, each a column left of the icon column (`ui`, the window's root, as +/// [`dock::show`] lays it out): a strip along its top that floats the column when dragged and +/// whose × puts its panels back in the dock, then its groups top to bottom. Returns the columns' +/// bounds, next to the icon column first. +pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec { + let t = Tokens::get(ui.ctx()); + let mut rects = vec![]; + let mut action = None; + for (_, set) in docked_sets(&app.ui) { + let groups = shown_groups(&app.ui, &set); + let Some(first) = groups.first().and_then(|g| g.ids.first().copied()) else { continue }; + let natural = groups.iter().flat_map(|g| g.ids.iter()).map(|id| dock::panel_width(id)).fold(200.0, f32::max); + let panel = egui::Panel::right(Id::new(("dock-column", first))) + .resizable(true) + .default_size(natural) + .size_range(200.0..=520.0) + .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border))) + .show(ui, |ui| { ui.spacing_mut().item_spacing = Vec2::ZERO; - let tabs: Vec<_> = ids - .iter() - .map(|id| { - let label = panel(id).map_or(*id, |(_, label)| label); - let color = if *id == active { t.text_strong } else { t.text_dim }; - ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color) - }) - .collect(); - let tabs_width: f32 = tabs.iter().map(|g| g.size().x + 24.0).sum(); - let width = ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max).max(given.unwrap_or(0.0)); - ui.set_width(width); - // Title bar: a grip that moves the group, and the × that docks it. + let width = ui.available_width(); let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover()); - ui.painter().rect_filled(bar, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_strip); + ui.painter().rect_filled(bar, 0.0, t.tab_strip); let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE)); - let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("title"), Sense::drag()); + let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("column-bar"), Sense::drag()); for k in 0..2 { let y = bar.center().y - 1.5 + k as f32 * 3.0; ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled)); } - if grip.drag_started() { - action = Some(Action::Move(active, pos)); + if grip.drag_started() + && let (Some(at), Some(&gi)) = (ui.input(|i| i.pointer.press_origin()), set.first()) + { + action = Some(Action::Undock(gi, at - bar.min)); } - grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move")); - let x = ui.interact(close, ui.id().with("close"), Sense::click()); + grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group")); + let x = ui.interact(close, ui.id().with("column-close"), Sense::click()); icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim }); if x.on_hover_text(tl!("Put back in the dock")).clicked() { - action = Some(Action::Close(gi)); + action = Some(Action::Close(set.clone())); } - // Tab strip: a click shows a tab, a drag out of the strip floats it on its own. - let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.panel_darker); - let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); - let tab_area = Rect::from_min_max(strip.min, pos2((menu.left() - 4.0).max(strip.left()), strip.bottom())); - let names: Vec<_> = ids.iter().map(|&id| (id, panel(id).map_or(id, |(_, label)| label))).collect(); - let selected = ids.iter().position(|&id| id == active).unwrap_or(0); - let out = dock::panel_tabs(ui, "tab", tab_area, &names, selected, theme::semibold(12.0), false); - for (k, resp) in out.responses { - let Some(&id) = ids.get(k) else { continue }; - if resp.clicked() { - action = Some(Action::Activate(gi, k)); - } else if lone && resp.drag_started() { - action = Some(Action::Move(id, pos)); - } else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) { - action = Some(Action::Tear(id, resp.rect.left())); - } - } - if let Some(k) = out.picked { - action = Some(Action::Activate(gi, k)); - } - let rest = Rect::from_min_max(pos2(out.right, strip.top()), pos2(menu.left() - 4.0, strip.bottom())); - if rest.width() > 0.0 { - let resp = ui.interact(rest, ui.id().with("strip"), Sense::drag()); - if resp.drag_started() { - action = Some(Action::Move(active, pos)); - } - resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move")); - } - panels::panel_menu(app, ui, active, menu); - dock::panel_body(app, ui, active, width, tall); + let n = groups.len() as f32; + let open = groups.iter().filter(|g| !g.collapsed).count().max(1) as f32; + // The open panels share the column's height, each at least `MIN_TALL`: more than fit + // scroll, with the column. + let tall = ((ui.available_height() - n * STRIP - (n - 1.0) * GAP - 24.0 * open) / open).max(MIN_TALL); + egui::ScrollArea::vertical().id_salt(("dock-column-scroll", first)).auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing = Vec2::ZERO; + let width = ui.available_width(); + let top = ui.cursor().top(); + draw_groups(app, ui, &groups, width, tall, top, None, &mut action); + }); }); - }); + rects.push(panel.response.rect); } - match action { - Some(Action::Activate(gi, k)) => { - if let Some(g) = app.ui.floating_panels.get_mut(gi) { - g.active = k; + ui.ctx().data_mut(|d| { + let icons = d.get_temp::(icons_rect_id()); + if let Some(left) = icons.map(|r| rects.iter().map(|r| r.left()).fold(r.left(), f32::min)) { + d.insert_temp(dock::dock_left_id(), left); + } + d.insert_temp(columns_id(), rects.clone()); + }); + let ctx = ui.ctx().clone(); + apply(app, &ctx, action); + rects +} + +/// Draw the set of floating groups `set` (top to bottom) as one floating box. +fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action: &mut Option) { + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let groups = shown_groups(&app.ui, set); + let Some((lead, first)) = groups.first().and_then(|g| Some((g, *g.ids.first()?))) else { return }; + let Some(saved) = app.ui.floating_panels.get(lead.gi).map(|g| g.pos) else { return }; + let given = set.iter().filter_map(|&i| app.ui.floating_panels.get(i)?.width).reduce(f32::max).map(|w| w.min(screen.width() - 16.0)); + let area = area_id(first); + let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(lead.active), 200.0), |r| r.size()); + let pos = clamp(saved, size, screen); + // The tabbed group's panels fill (Layers) or scroll (Properties) to near the window's bottom, + // leaving room for their margins (a group that only just fitted would creep up a little each + // frame); the open panels of a set share that height. + let n = groups.len() as f32; + let open = groups.iter().filter(|g| !g.collapsed).count().max(1) as f32; + let tall = ((screen.bottom() - pos.y - TITLE - n * STRIP - (n - 1.0) * GAP - 60.0) / open).clamp(120.0, 560.0); + let lead_active = lead.active; + egui::Area::new(area).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { + let frame = egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO); + frame.show(ui, |ui| { + ui.spacing_mut().item_spacing = Vec2::ZERO; + let width = groups + .iter() + .map(|g| { + let tabs_width: f32 = g + .ids + .iter() + .map(|id| { + let label = panel(id).map_or(*id, |(_, label)| label); + ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0 + }) + .sum(); + g.ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max) + }) + .fold(given.unwrap_or(0.0), f32::max); + ui.set_width(width); + // Title bar: a grip that moves the set, and the × that docks it. + let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover()); + ui.painter().rect_filled(bar, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_strip); + let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE)); + let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("title"), Sense::drag()); + for k in 0..2 { + let y = bar.center().y - 1.5 + k as f32 * 3.0; + ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled)); + } + if grip.drag_started() { + *action = Some(Action::Move(lead_active, pos)); + } + grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move")); + let x = ui.interact(close, ui.id().with("close"), Sense::click()); + icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim }); + if x.on_hover_text(tl!("Put back in the dock")).clicked() { + *action = Some(Action::Close(set.to_vec())); + } + let alone = (groups.len() == 1).then_some(pos); + draw_groups(app, ui, &groups, width, tall, bar.top(), alone, action); + }); + }); +} + +/// Draw `groups` top to bottom, `width` wide, their open panels `tall`, the first one's bar having +/// begun at `top`: a bar between groups, each group's tab strip, then its shown panel. `alone`, a +/// lone floating group drawn at that corner: its tab strip moves it (else it takes its group out of +/// the set or dock column). +#[allow(clippy::too_many_arguments)] +fn draw_groups( + app: &mut VectorcraftApp, + ui: &mut egui::Ui, + groups: &[Shown], + width: f32, + tall: f32, + top: f32, + alone: Option, + action: &mut Option, +) { + let t = Tokens::get(ui.ctx()); + let pointer = ui.input(|i| i.pointer.interact_pos()); + for (k, g) in groups.iter().enumerate() { + let top = if k == 0 { + top + } else { + let (gap, _) = ui.allocate_exact_size(vec2(width, GAP), Sense::hover()); + ui.painter().rect_filled(gap, 0.0, t.tab_strip); + gap.top() + }; + // Tab strip: a click shows a tab, a double-click collapses the group, a drag out of the + // strip floats a tab on its own; a lone tab or the strip right of the tabs moves the group + // (out of its set). + let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.panel_darker); + let lone = g.ids.len() == 1; + let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); + let tab_area = Rect::from_min_max(strip.min, pos2((menu.left() - 4.0).max(strip.left()), strip.bottom())); + let names: Vec<_> = g.ids.iter().map(|&id| (id, panel(id).map_or(id, |(_, label)| label))).collect(); + let selected = g.ids.iter().position(|&id| id == g.active).unwrap_or(0); + let out = dock::panel_tabs(ui, &format!("native-set-tabs-{}", g.gi), tab_area, &names, selected, theme::semibold(12.0), false); + for (j, resp) in out.responses { + let Some(&id) = g.ids.get(j) else { continue }; + if resp.double_clicked() { + *action = Some(Action::Collapse(g.gi)); + } else if resp.clicked() { + *action = Some(Action::Activate(g.gi, j)); + } else if lone && resp.drag_started() { + *action = Some(match alone { + Some(pos) => Action::Move(id, pos), + None => Action::Detach(g.gi, strip.left()), + }); + } else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) { + *action = Some(Action::Tear(id, resp.rect.left())); } } - Some(Action::Close(gi)) => { - if let Some(g) = app.ui.floating_panels.get(gi) { - let ids = ids_of(g); - let active = ids.get(g.active).copied().unwrap_or_default(); - dock(&mut app.ui, &ids, active); - } + if let Some(j) = out.picked { + *action = Some(Action::Activate(g.gi, j)); } - Some(Action::Move(id, pos)) => { - // From where it is drawn (a position saved on a bigger window was clamped into this one). - if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) { - g.pos = [pos.x, pos.y]; - } - if let Some(at) = pointer { - start(app, ctx, Moving::Panel(id), at - pos); + let rest = Rect::from_min_max(pos2(out.right, strip.top()), pos2(menu.left() - 4.0, strip.bottom())); + if rest.width() > 0.0 { + let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag()); + if resp.drag_started() { + *action = Some(match alone { + Some(pos) => Action::Move(g.active, pos), + None => Action::Detach(g.gi, strip.left()), + }); } + let tip = if alone.is_some() { tl!("Drag to move") } else { tl!("Drag to move out of the set") }; + resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip); + } + panels::panel_menu(app, ui, g.active, menu); + if !g.collapsed { + dock::panel_body(app, ui, g.active, width, tall); + } + // Where the next widget would go: in a dock column, the ui's own bounds reach its bottom. + let bottom = ui.cursor().top().max(strip.bottom()); + if let Some(&id) = g.ids.first() { + let rect = Rect::from_min_max(pos2(strip.left(), top), pos2(strip.left() + width, bottom)); + ui.ctx().data_mut(|d| d.insert_temp(group_rect_id(id), rect)); } - Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)), - None => {} } } @@ -507,35 +968,121 @@ fn float_or_dock(app: &mut VectorcraftApp, float_it: bool, p: &Value) -> Result< (true, None) if DockTab::from_id(id).is_some() => docked_tabs(&app.ui).map(|t| t.info().0).collect(), _ => vec![id], }; - app.ui.dock = true; - if !float_it { - dock(&mut app.ui, &ids, id); - return Ok(json!({ "panel": id, "floating": false })); + let collapsed = crate::menus::opt_bool(p, "collapsed")?; + let onto = p.get("onto").and_then(Value::as_str); + let below = p.get("below").and_then(Value::as_str); + let above = p.get("above").and_then(Value::as_str); + if [onto, below, above].iter().filter(|o| o.is_some()).count() > 1 { + return Err("give only one of onto, below and above".into()); } - match p.get("onto").and_then(Value::as_str) { + let slot = column_slot(&app.ui, p)?; + app.ui.dock = true; + // Its own group already (not a panel taken out of its group). + let whole = own.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.panels.len() == ids.len())); + if !float_it { + let Some(slot) = slot else { + dock(&mut app.ui, &ids, id); + return Ok(json!({ "panel": id, "floating": false })); + }; + // A dock column of its own group (floated out of its group first) and the rest of its set. + if whole.is_none() { + float(&mut app.ui, &ids, id, Pos2::ZERO); + } + if let Some(gi) = group_of(&app.ui, id) { + let set = set_of(&app.ui, gi); + dock_column(&mut app.ui, &set, slot); + } + let column = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)).and_then(|g| g.docked); + return Ok(json!({ "panel": id, "floating": false, "column": column })); + } + // Floating a docked group floats its dock column. + if let Some(gi) = whole.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.docked.is_some())) { + for i in set_of(&app.ui, gi) { + if let Some(g) = app.ui.floating_panels.get_mut(i) { + g.docked = None; + } + } + FloatingPanels::tidy(&mut app.ui.floating_panels); + } + if let Some((next, under)) = below.map(|b| (b, true)).or(above.map(|a| (a, false))) { + let next = crate::menus::normalize_panel(next) + .filter(|o| !ids.contains(o) && group_of(&app.ui, o).is_some()) + .ok_or("below and above must name a panel floating in another group")?; + // The panels as a group of their own (their group already, or floated out), in the set. + if whole.is_none() { + float(&mut app.ui, &ids, id, Pos2::ZERO); + } + if let Some(gi) = group_of(&app.ui, id) { + attach(&mut app.ui, &[gi], next, under); + } + } else { + float_onto(app, &ids, id, own, onto, at)?; + } + if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) { + g.active = g.panels.iter().position(|q| q == id).unwrap_or(g.active); + if let Some(c) = collapsed { + g.collapsed = c; + } + } + let gi = group_of(&app.ui, id); + let g = gi.and_then(|i| app.ui.floating_panels.get(i)); + let set: Vec<&Vec> = + gi.map(|i| set_of(&app.ui, i)).unwrap_or_default().iter().filter_map(|&i| app.ui.floating_panels.get(i)).map(|g| &g.panels).collect(); + let (group, pos, collapsed, column) = (g.map(|g| &g.panels), g.map(|g| g.pos), g.is_some_and(|g| g.collapsed), g.and_then(|g| g.docked)); + Ok(json!({ "panel": id, "floating": column.is_none(), "group": group, "set": set, "pos": pos, "collapsed": collapsed, "column": column })) +} + +/// The `column` param of `window.panel.dock`: `true` for a new dock column at the dock's left, or +/// the place of the new column (1: next to the icon column, counting left); none to dock the panel +/// where it lives. +fn column_slot(ui: &UiState, p: &Value) -> Result, String> { + let next = ui.floating_panels.iter().filter_map(|g| g.docked).max().unwrap_or(0).saturating_add(1); + match p.get("column") { + None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None), + Some(Value::Bool(true)) => Ok(Some(next)), + Some(v) => match v.as_u64().filter(|n| *n >= 1) { + Some(n) => Ok(Some(u32::try_from(n).unwrap_or(u32::MAX).min(next))), + None => Err("column must be true or a number from 1".into()), + }, + } +} + +/// Float `ids` (`id` among them; `own`, the group holding `id`) as their own group at `at`, or +/// stacked as tabs `onto` another group. +fn float_onto( + app: &mut VectorcraftApp, + ids: &[&'static str], + id: &'static str, + own: Option, + onto: Option<&str>, + at: Option, +) -> Result<(), String> { + match onto { Some(onto) => { let onto = crate::menus::normalize_panel(onto) .filter(|o| !ids.contains(o) && group_of(&app.ui, o).is_some_and(|g| Some(g) != own)) .ok_or("onto must name a panel floating in another group")?; - stack(&mut app.ui, &ids, id, onto); + stack(&mut app.ui, ids, id, onto); } // Its own group already: moved there (or left where it is), showing the panel. None => match own.and_then(|i| app.ui.floating_panels.get_mut(i)).filter(|g| g.panels.len() == ids.len()) { + // Moved to `at` takes it out of its set; else it stays where it is. Some(g) => { - g.active = g.panels.iter().position(|p| p == id).unwrap_or(g.active); if let Some(at) = at { g.pos = [at.x, at.y]; + g.column = None; + g.docked = None; + FloatingPanels::tidy(&mut app.ui.floating_panels); } } None => { let n = app.ui.floating_panels.len() as f32; let at = at.unwrap_or(pos2(FLOAT_AT[0] + 24.0 * n, FLOAT_AT[1] + 24.0 * n)); - float(&mut app.ui, &ids, id, at); + float(&mut app.ui, ids, id, at); } }, } - let g = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)); - Ok(json!({ "panel": id, "floating": true, "group": g.map(|g| &g.panels), "pos": g.map(|g| g.pos) })) + Ok(()) } #[cfg(test)] @@ -629,7 +1176,8 @@ mod tests { /// The floating group whose first panel is `first`, as drawn. fn group(&self, first: &str) -> Rect { - let id = if self.app.ui.docking.is_some() { + let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated); + let id = if self.app.ui.docking.is_some() && !native { Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())) } else { area_id(first) @@ -639,7 +1187,8 @@ mod tests { /// A point on a floating group's title bar (left of its ×). fn title(&self, first: &str) -> Pos2 { - if self.app.ui.docking.is_some() { + let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated); + if self.app.ui.docking.is_some() && !native { let rect = self.group(first); return rect.left_top() + vec2(6.0, 16.0); } @@ -836,9 +1385,37 @@ mod tests { if reason != "workspace" { assert_eq!(h.app.ui.open_panel, original.open_panel); } + if reason == "focus" { + h.frame(vec![Event::WindowFocused(true)]); + } + for step in 1..=3 { + h.frame(vec![Event::PointerMoved(to + vec2(15.0 * step as f32, 8.0))]); + assert!(h.app.ui.floating_panels.is_empty(), "{reason}: canceled drag stays canceled on held frame {step}"); + } + h.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Secondary, pressed: true, modifiers: Default::default() }]); + assert!(h.app.ui.floating_panels.is_empty(), "{reason}: secondary press does not restart primary gesture"); + h.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Secondary, pressed: false, modifiers: Default::default() }]); + h.release(to); + assert!(h.app.ui.floating_panels.is_empty(), "{reason}: release does not restart canceled gesture"); } } + #[test] + fn docked_tools_drag_cancellation_restores_the_docked_origin() { + let mut h = Harness::new(); + let zone = h.temp_rect(tools_zone_id()); + let title = zone.left_top() + vec2(30.0, 7.0); + let to = pos2(400.0, 300.0); + h.hold(title, to); + assert!(h.app.ui.toolbar_pos.is_some()); + h.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + assert_eq!(h.app.ui.toolbar_pos, None, "cancel restores docked Tools origin"); + h.frame(vec![Event::PointerMoved(to + vec2(30.0, 20.0))]); + assert_eq!(h.app.ui.toolbar_pos, None); + h.release(to); + assert_eq!(h.app.ui.toolbar_pos, None); + } + #[test] fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() { let mut h = Harness::new(); @@ -885,7 +1462,7 @@ mod tests { })) .unwrap(); let edited = edited.sanitized(); - assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0], width: None }]); + assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0], ..Default::default() }]); h.settle(); let g = h.group("layers"); let screen = Rect::from_min_size(Pos2::ZERO, SCREEN); @@ -921,6 +1498,326 @@ mod tests { assert!(app.ui.toolbar && app.ui.toolbar_pos == Some(TOOLS_AT), "floating shows the Tools panel"); } + /// The groups' sets: each group's panels and its column, in list order. + fn sets(groups: &[FloatingPanels]) -> Vec<(Vec<&str>, Option)> { + groups.iter().map(|g| (g.panels.iter().map(String::as_str).collect(), g.column)).collect() + } + + #[test] + fn groups_dropped_on_an_edge_stack_into_a_set_that_moves_together_and_tears_apart() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "color", "x": 500, "y": 150})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "swatches", "x": 950, "y": 450})).unwrap(); + h.settle(); + // Swatches' title bar held over Color's bottom edge: a line shows it goes below. + let color = h.temp_rect(group_rect_id("color")); + let below = pos2(color.center().x, color.bottom() + 2.0); + h.hold(h.title("swatches"), below); + let target = drop_target(&h.app, &h.ctx, Moving::Panel("swatches"), below); + assert_eq!(target.map(|t| t.0), Some(Drop::Below("color"))); + assert!(target.is_some_and(|t| t.1.height() <= 4.0), "a line, not a box: {target:?}"); + h.release(below); + let s = sets(&h.app.ui.floating_panels); + assert_eq!((s[0].0.clone(), s[1].0.clone()), (vec!["color"], vec!["swatches"]), "Color, then Swatches"); + assert!(s[0].1.is_some() && s[0].1 == s[1].1, "one set: {s:?}"); + assert_eq!(h.app.ui.floating_panels[0].pos, h.app.ui.floating_panels[1].pos, "at the set's corner"); + // Drawn as one box: Swatches right under Color. + let (color, swatches) = (h.temp_rect(group_rect_id("color")), h.temp_rect(group_rect_id("swatches"))); + assert!((swatches.top() - color.bottom()).abs() < 1.0 && swatches.left() == color.left(), "{color:?} {swatches:?}"); + assert!(h.group("color").contains_rect(swatches.shrink(1.0)), "in Color's area"); + // The set moves by its title bar. + let to = pos2(300.0, 120.0); + let grab = h.title("color") - h.group("color").min; + h.drag(h.title("color"), to); + let moved = [to.x - grab.x, to.y - grab.y]; + assert!(h.app.ui.floating_panels.iter().all(|g| (g.pos[0] - moved[0]).abs() < 1.0 && (g.pos[1] - moved[1]).abs() < 1.0)); + assert_eq!(sets(&h.app.ui.floating_panels).len(), 2); + // Swatches' lone tab dragged away takes it out of the set: both float on their own. + let swatches = h.temp_rect(group_rect_id("swatches")); + let tab = pos2(swatches.left() + 20.0, swatches.top() + GAP + STRIP / 2.0); + let away = pos2(1000.0, 600.0); + h.drag(tab, away); + assert_eq!(sets(&h.app.ui.floating_panels), [(vec!["color"], None), (vec!["swatches"], None)]); + assert!(h.group("swatches").contains(away), "under the pointer: {:?}", h.group("swatches")); + // Dropped on Color's top edge, it goes above Color, which it now heads. + let color = h.temp_rect(group_rect_id("color")); + let above = pos2(color.center().x, color.top() - 3.0); + h.hold(h.title("swatches"), above); + assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("swatches"), above).map(|t| t.0), Some(Drop::Above("color"))); + h.release(above); + let s = sets(&h.app.ui.floating_panels); + assert_eq!((s[0].0.clone(), s[1].0.clone()), (vec!["swatches"], vec!["color"])); + assert!(s[0].1.is_some() && s[0].1 == s[1].1); + // A group's tab strip still takes tabs: Stroke dropped on Color's tabs joins Color's group. + h.app.run("window.panel.float", json!({"panel": "stroke", "x": 1000, "y": 650})).unwrap(); + h.settle(); + let color = h.temp_rect(group_rect_id("color")); + let tabs = pos2(color.center().x, color.top() + GAP + STRIP / 2.0); + h.drag(h.title("stroke"), tabs); + assert_eq!(sets(&h.app.ui.floating_panels).iter().map(|s| s.0.clone()).collect::>(), [vec!["swatches"], vec!["color", "stroke"]]); + // The set's × docks every group. + let g = h.group("swatches"); + h.click(pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0)); + assert!(h.groups().is_empty(), "docked: {:?}", h.groups()); + } + + #[test] + fn a_double_click_on_a_tab_collapses_a_floating_group_and_another_expands_it() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 150})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "stroke", "below": "layers"})).unwrap(); + h.settle(); + let open = h.temp_rect(group_rect_id("layers")); + let tab = pos2(open.left() + 20.0, open.top() + 1.0 + TITLE + STRIP / 2.0); + let double = |h: &mut Harness| { + // Well after any earlier click, so the first click doesn't make a double-click with it. + h.time += 1.0; + h.frame(vec![Event::PointerMoved(tab), button(tab, true)]); + h.frame(vec![button(tab, false)]); + h.frame(vec![button(tab, true)]); + h.frame(vec![button(tab, false)]); + h.settle(); + }; + double(&mut h); + assert!(h.app.ui.floating_panels[0].collapsed, "collapsed"); + let shut = h.temp_rect(group_rect_id("layers")); + assert!(shut.height() < 1.0 + TITLE + STRIP + 2.0, "only its tabs: {shut:?}"); + assert!(h.temp_rect(group_rect_id("stroke")).top() < open.bottom(), "Stroke moved up under it"); + double(&mut h); + assert!(!h.app.ui.floating_panels[0].collapsed, "expanded"); + // Collapsed by command, Window › Layers expands it. + h.app.run("window.panel.float", json!({"panel": "layers", "collapsed": true})).unwrap(); + assert!(h.app.ui.floating_panels[0].collapsed); + h.app.run("window.panel", json!({"panel": "layers"})).unwrap(); + assert!(!h.app.ui.floating_panels[0].collapsed); + } + + #[test] + fn sets_by_command_are_checked_and_kept_in_workspaces_and_preferences() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.panel.float", json!({"panel": "layers", "x": 100, "y": 50})).unwrap(); + assert!(app.run("window.panel.float", json!({"panel": "color", "below": "swatches"})).is_err(), "Swatches isn't floating"); + assert!(app.run("window.panel.float", json!({"panel": "color", "onto": "layers", "below": "layers"})).is_err(), "one of them"); + assert!(app.run("window.panel.float", json!({"panel": "layers", "below": "layers"})).is_err(), "not below itself"); + assert!(app.run("window.panel.float", json!({"panel": "layers", "collapsed": "yes"})).is_err()); + assert_eq!(app.ui.floating_panels.len(), 1, "nothing floated by the errors"); + // Color floats straight into the set, below Layers; Stroke above it. + let r = app.run("window.panel.float", json!({"panel": "color", "below": "Layers"})).unwrap(); + assert_eq!(r["set"], json!([["layers"], ["color"]])); + let r = app.run("window.panel.float", json!({"panel": "stroke", "above": "color"})).unwrap(); + assert_eq!(r["set"], json!([["layers"], ["stroke"], ["color"]])); + assert!(app.ui.floating_panels.iter().all(|g| g.pos == [100.0, 50.0]), "at the set's corner"); + // Stroke's whole group moves to the set's bottom. + app.run("window.panel.float", json!({"panel": "stroke", "below": "color"})).unwrap(); + assert_eq!(sets(&app.ui.floating_panels).iter().map(|s| s.0.clone()).collect::>(), [vec!["layers"], vec!["color"], vec!["stroke"]]); + // Kept in a workspace and the preferences. + app.run("window.panel.float", json!({"panel": "color", "collapsed": true})).unwrap(); + app.run("window.workspace.new", json!({"name": "Sets"})).unwrap(); + let saved = app.ui.floating_panels.clone(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + assert!(app.ui.floating_panels.is_empty()); + app.run("window.workspace", json!({"name": "Sets"})).unwrap(); + assert_eq!(app.ui.floating_panels, saved); + let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(prefs.sanitized().floating_panels, saved); + // Moved to x, y, a group leaves its set; the set left with one group floats on its own. + let r = app.run("window.panel.float", json!({"panel": "color", "x": 600, "y": 400})).unwrap(); + assert_eq!((r["set"].clone(), r["pos"].clone()), (json!([["color"]]), json!([600.0, 400.0]))); + app.run("window.panel.dock", json!({"panel": "layers"})).unwrap(); + assert_eq!(sets(&app.ui.floating_panels), [(vec!["stroke"], None), (vec!["color"], None)]); + // A hand-edited file: a set's groups kept together at its first group's corner, a lone one alone. + let edited: UiState = serde_json::from_value(json!({ + "floating_panels": [ + {"panels": ["layers"], "pos": [1, 2], "column": 4}, + {"panels": ["color"], "pos": [9, 9]}, + {"panels": ["stroke"], "pos": [5, 6], "column": 4}, + {"panels": ["swatches"], "pos": [7, 8], "column": 9}, + ], + })) + .unwrap(); + let edited = edited.sanitized(); + let s = sets(&edited.floating_panels); + assert_eq!(s, [(vec!["layers"], Some(4)), (vec!["stroke"], Some(4)), (vec!["color"], None), (vec!["swatches"], None)]); + assert_eq!(edited.floating_panels[1].pos, [1.0, 2.0]); + } + + /// The dock columns as drawn, next to the icon column first. + fn columns(h: &Harness) -> Vec { + h.ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default() + } + + /// Each group's panels and dock column, in list order. + fn docked_of(groups: &[FloatingPanels]) -> Vec<(Vec<&str>, Option)> { + groups.iter().map(|g| (g.panels.iter().map(String::as_str).collect(), g.docked)).collect() + } + + #[test] + fn sets_dropped_on_the_docks_left_edge_become_dock_columns() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "color", "x": 300, "y": 150})).unwrap(); + h.settle(); + // Held over the icon column's left edge, a line shows the new column; dropped, it docks. + let icons = h.temp_rect(icons_rect_id()); + let edge = pos2(icons.left() - 2.0, icons.center().y); + h.hold(h.title("color"), edge); + let target = drop_target(&h.app, &h.ctx, Moving::Panel("color"), edge); + assert_eq!(target.map(|t| t.0), Some(Drop::Column(1))); + assert!(target.is_some_and(|t| t.1.width() <= 4.0), "a line: {target:?}"); + h.release(edge); + assert_eq!(docked_of(&h.app.ui.floating_panels), [(vec!["color"], Some(1))]); + let cols = columns(&h); + assert_eq!(cols.len(), 1); + let icons = h.temp_rect(icons_rect_id()); + assert!((cols[0].right() - icons.left()).abs() < 2.0, "right of it the icon column: {cols:?} {icons:?}"); + // Drawn in the column, not floating too (a floating box, drawn after the dock, would move it). + assert!(cols[0].contains_rect(h.temp_rect(group_rect_id("color")).shrink(1.0)), "Color in the column"); + // Swatches dropped on the docked Color's bottom edge joins its column. + h.app.run("window.panel.float", json!({"panel": "swatches", "x": 300, "y": 300})).unwrap(); + h.settle(); + let color = h.temp_rect(group_rect_id("color")); + let below = pos2(color.center().x, color.bottom() + 2.0); + h.drag(h.title("swatches"), below); + assert_eq!(docked_of(&h.app.ui.floating_panels), [(vec!["color"], Some(1)), (vec!["swatches"], Some(1))]); + assert!(columns(&h)[0].contains_rect(h.temp_rect(group_rect_id("swatches")).shrink(1.0))); + // Stroke dropped on the column's left edge makes a second column, left of it. + h.app.run("window.panel.float", json!({"panel": "stroke", "x": 300, "y": 400})).unwrap(); + h.settle(); + let col = columns(&h)[0]; + h.drag(h.title("stroke"), pos2(col.left() - 2.0, col.center().y)); + assert_eq!(docked_of(&h.app.ui.floating_panels).last(), Some(&(vec!["stroke"], Some(2)))); + let cols = columns(&h); + assert!(cols.len() == 2 && cols[1].right() <= cols[0].left() + 2.0, "{cols:?}"); + // A docked lone tab dragged away floats its group, leaving the rest of the column docked. + let swatches = h.temp_rect(group_rect_id("swatches")); + h.drag(pos2(swatches.left() + 20.0, swatches.top() + GAP + STRIP / 2.0), pos2(500.0, 500.0)); + let d = docked_of(&h.app.ui.floating_panels); + assert!(d.contains(&(vec!["swatches"], None)) && d.contains(&(vec!["color"], Some(1))), "{d:?}"); + // The column's top strip floats the whole column. + let col = columns(&h)[0]; + h.drag(pos2(col.left() + 30.0, col.top() + TITLE / 2.0), pos2(500.0, 200.0)); + let d = docked_of(&h.app.ui.floating_panels); + assert!(d.contains(&(vec!["color"], None)) && d.contains(&(vec!["stroke"], Some(1))), "renumbered: {d:?}"); + assert!(h.group("color").contains(pos2(500.0, 200.0)), "under the pointer"); + // The column's × puts its panels back in the dock. + let col = columns(&h)[0]; + h.click(pos2(col.right() - 11.0, col.top() + TITLE / 2.0)); + assert!(!h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"stroke".to_string()))); + assert!(columns(&h).is_empty()); + } + + #[test] + fn dock_columns_by_command_are_checked_and_kept_in_workspaces() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + assert!(app.run("window.panel.dock", json!({"panel": "color", "column": 0})).is_err()); + assert!(app.run("window.panel.dock", json!({"panel": "color", "column": "left"})).is_err()); + assert!(app.ui.floating_panels.is_empty()); + let r = app.run("window.panel.dock", json!({"panel": "color", "column": true})).unwrap(); + assert_eq!((r["column"].clone(), r["floating"].clone()), (json!(1), json!(false))); + app.run("window.panel.dock", json!({"panel": "layers", "column": true})).unwrap(); + // Next to the icon column, moving Color and Layers one column left. + app.run("window.panel.dock", json!({"panel": "stroke", "column": 1})).unwrap(); + app.run("window.panel.float", json!({"panel": "gradient", "below": "stroke"})).unwrap(); + let d = docked_of(&app.ui.floating_panels); + for (panel, column) in [("stroke", 1), ("gradient", 1), ("color", 2), ("layers", 3)] { + assert!(d.contains(&(vec![panel], Some(column))), "{panel} in column {column}: {d:?}"); + } + // Kept in a workspace and the preferences. + app.run("window.workspace.new", json!({"name": "Columns"})).unwrap(); + let saved = app.ui.floating_panels.clone(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + app.run("window.workspace", json!({"name": "Columns"})).unwrap(); + assert_eq!(app.ui.floating_panels, saved); + let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(prefs.sanitized().floating_panels, saved); + // Floated, Color's column leaves the dock and the columns close up. + let r = app.run("window.panel.float", json!({"panel": "color"})).unwrap(); + assert_eq!((r["floating"].clone(), r["column"].clone()), (json!(true), json!(null))); + assert!(docked_of(&app.ui.floating_panels).contains(&(vec!["layers"], Some(2)))); + // A hand-edited file: columns numbered without gaps, a set's groups in its first one's. + let edited: UiState = serde_json::from_value(json!({ + "floating_panels": [ + {"panels": ["layers"], "pos": [1, 2], "docked": 7}, + {"panels": ["color"], "pos": [1, 2], "column": 3, "docked": 3}, + {"panels": ["stroke"], "pos": [5, 6], "column": 3}, + ], + })) + .unwrap(); + let edited = edited.sanitized(); + let d = docked_of(&edited.floating_panels); + assert_eq!(d, [(vec!["layers"], Some(2)), (vec!["color"], Some(1)), (vec!["stroke"], Some(1))]); + } + + #[test] + fn a_dock_column_takes_groups_anywhere_in_it_and_as_tabs() { + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "color", "column": true})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "swatches", "below": "color"})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "stroke", "below": "swatches"})).unwrap(); + h.settle(); + let order = |h: &Harness| h.app.ui.floating_panels.iter().map(|g| g.panels.join("+")).collect::>(); + // Dropped on the upper half of Swatches, Gradient goes in right above it. + h.app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 200})).unwrap(); + h.settle(); + let r = h.temp_rect(group_rect_id("swatches")); + let tabs_bottom = r.top() + GAP + STRIP; + let upper = pos2(r.center().x, tabs_bottom + (r.bottom() - tabs_bottom) / 4.0); + h.hold(h.title("gradient"), upper); + assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("gradient"), upper).map(|t| t.0), Some(Drop::Above("swatches"))); + h.release(upper); + assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke"]); + assert!(h.app.ui.floating_panels.iter().all(|g| g.docked == Some(1)), "all in the column"); + // Dropped on Stroke's tabs, Transparency joins Stroke's group as a tab (Color and Gradient + // collapsed, so Stroke is in view in the column, which scrolls). + for g in h.app.ui.floating_panels.iter_mut().take(2) { + g.collapsed = true; + } + h.app.run("window.panel.float", json!({"panel": "transparency", "x": 300, "y": 200})).unwrap(); + h.settle(); + let r = h.temp_rect(group_rect_id("stroke")); + let tabs = pos2(r.left() + 120.0, r.top() + GAP + STRIP / 2.0); + h.hold(h.title("transparency"), tabs); + assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("transparency"), tabs).map(|t| t.0), Some(Drop::Stack("stroke"))); + h.release(tabs); + assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke+transparency"]); + // Dropped in the room under the groups (three collapsed to make some), Align goes at the + // column's bottom. + for g in h.app.ui.floating_panels.iter_mut().take(3) { + g.collapsed = true; + } + h.app.run("window.panel.float", json!({"panel": "align", "x": 300, "y": 200})).unwrap(); + h.settle(); + let col = columns(&h)[0]; + h.drag(h.title("align"), pos2(col.center().x, col.bottom() - 6.0)); + assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke+transparency", "align"]); + assert!(h.app.ui.floating_panels.iter().all(|g| g.docked == Some(1))); + } + + #[test] + fn a_dock_column_too_tall_for_the_window_scrolls() { + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "layers", "column": true})).unwrap(); + let mut above = "layers"; + for p in ["properties", "color", "swatches", "stroke", "gradient", "transparency", "align"] { + h.app.run("window.panel.float", json!({"panel": p, "below": above})).unwrap(); + above = p; + } + h.settle(); + let col = columns(&h)[0]; + assert!(col.bottom() <= SCREEN.y + 1.0, "the column stays in the window: {col:?}"); + let last = h.temp_rect(group_rect_id("align")); + assert!(last.top() > col.bottom(), "Align starts below the window: {last:?} {col:?}"); + // The wheel over the column (Color's tabs, outside any panel's own scrolling) scrolls it, + // bringing Align up. + let color = h.temp_rect(group_rect_id("color")); + let over = pos2(color.left() + 150.0, color.top() + GAP + STRIP / 2.0); + for _ in 0..4 { + h.frame(crate::toolbar::tests::wheel(over, 300.0)); + } + h.settle(); + let moved = h.temp_rect(group_rect_id("align")); + assert!(moved.top() < last.top() - 100.0, "scrolled up: {moved:?} from {last:?}"); + } + fn floating_pos(app: &VectorcraftApp, id: &str) -> [f32; 2] { group_of(&app.ui, id).map(|i| app.ui.floating_panels[i].pos).unwrap() } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 393c4a18d..345eb587c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -628,6 +628,7 @@ % of all commits % všech commitů Lines +/− Řádky +/− Drag to move Přetažením přesunete + Drag to move out of the set Přetažením přesunete mimo sadu Drag to float as a toolbar Přetažením vytvoříte plovoucí panel nástrojů Put back in the toolbar Vrátit do panelu nástrojů Float Panel Plovoucí panel @@ -702,3 +703,42 @@ Group with Seskupit s Resize panels Změnit velikost panelů Resize panel window Změnit velikost okna panelu + +# 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… + Vertical Guide Svislé vodítko + Horizontal Guide Vodorovné vodítko diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6cbb8b2cf..513d77e51 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -362,6 +362,9 @@ Position Panel Above Text Bedienfeld über dem Text positionieren Clear Appearance Aussehen löschen Clear Guides Hilfslinien löschen + Vertical Guide Vertikale Hilfslinie + Horizontal Guide Horizontale Hilfslinie + Move Guide to Layer Hilfslinie auf Ebene verschieben Clear Overrides Abweichungen löschen Clear Recent Files Liste der zuletzt geöffneten Dateien leeren Click Klicken @@ -1997,6 +2000,7 @@ Proof Colors Farbproof Proof Setup Proof einrichten Properties Eigenschaften + Proportional Metrics Proportionale Metrik Proportional Oldstyle Proportionale Mediävalziffern Pucker & Bloat… Zusammenziehen und aufblasen… Pucker Tool Zusammenziehen-Werkzeug @@ -3213,6 +3217,7 @@ axis Vertical Vertikal axis Both Beide theme Appearance Erscheinungsbild Drag to move Zum Verschieben ziehen + Drag to move out of the set Ziehen, um aus dem Satz zu lösen Drag to float as a toolbar Ziehen, um als eigene Werkzeugleiste abzulösen Put back in the toolbar Zurück in die Werkzeugleiste setzen Float Panel Bedienfeld ablösen @@ -3316,6 +3321,7 @@ theme Appearance Erscheinungsbild @msg `color` must be a colour `color` muss eine Farbe sein @msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` muss ein Befehl sein, dessen Menüeintrag ein Dialogfeld öffnet (siehe ui.menuDialog) @msg `freeform.points` must be an array `freeform.points` muss ein Array sein +@msg `freeform.lines` must be an array `freeform.lines` muss ein Array sein @msg `gradient` must be an object `gradient` muss ein Objekt sein @msg `library` is a library id or name (see {list}) `library` ist die ID oder der Name einer Bibliothek (siehe {list}) @msg `mode` applies to freeform gradients `mode` gilt für Freiformverläufe @@ -3417,6 +3423,7 @@ theme Appearance Erscheinungsbild @msg empty path leerer Pfad @msg file version {0} is newer than this VectorCraft supports ({VERSION}) die Dateiversion {0} ist neuer, als dieses VectorCraft unterstützt ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` Freiformpunkt {i} braucht `at` [x, y] und eine gültige `color` +@msg freeform line {i} must be an array of point indices Freiformlinie {i} muss ein Array von Punktindizes sein @msg give both `start` and `end` [x, y] (or neither) geben Sie sowohl `start` als auch `end` [x, y] an (oder keines von beiden) @msg give corners of the path: anchors between two straight sides geben Sie Ecken des Pfads an: Ankerpunkte zwischen zwei geraden Seiten @msg give the path `id` and the width point `index` geben Sie die `id` des Pfads und den `index` des Breitenpunkts an @@ -3656,6 +3663,8 @@ theme Appearance Erscheinungsbild @msg the image's pixels don't match its size die Pixel des Bilds passen nicht zu seiner Größe @msg the layer has no objects to clip by die Ebene hat keine Objekte, an denen beschnitten werden könnte @msg the layer or group is locked die Ebene oder Gruppe ist gesperrt +@msg the guide is on a hidden or locked layer die Hilfslinie liegt auf einer ausgeblendeten oder gesperrten Ebene +@msg the target layer is locked or hidden die Zielebene ist gesperrt oder ausgeblendet @msg the layer to flatten into is hidden die Ebene, auf die reduziert werden soll, ist ausgeblendet @msg the object already has an opacity mask das Objekt hat bereits eine Deckkraftmaske @msg the object has no geometry das Objekt hat keine Geometrie @@ -3680,7 +3689,7 @@ theme Appearance Erscheinungsbild @msg the {tool} tool has no options das Werkzeug „{tool}“ hat keine Optionen @msg there are no objects to release es gibt keine Objekte zum Zurückwandeln @msg there are no printer settings to open here hier lassen sich keine Druckereinstellungen öffnen -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it diese PostScript-Datei kann nicht gelesen werden ({_1}): Speichern Sie sie in der App, mit der sie erstellt wurde, als PDF oder SVG +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it diese PostScript-Datei kann nicht gelesen werden ({why}): Speichern Sie sie in der App, mit der sie erstellt wurde, als PDF oder SVG @msg this file's PostScript couldn't be read ({why}): {what} das PostScript dieser Datei konnte nicht gelesen werden ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens dieses Format fasst eine Zeichenfläche: Exportieren Sie mehrere als PDF oder mit document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that dies ist eine DWG-Zeichnung, die nicht gelesen werden kann: Speichern Sie sie in der CAD-App als ASCII-DXF und öffnen Sie diese Datei @@ -3768,6 +3777,9 @@ theme Appearance Erscheinungsbild @msg tool (a tool id) is required tool (eine Werkzeug-ID) ist erforderlich @msg {tool} isn't in a toolbar group of several tools {tool} gehört zu keiner Werkzeuggruppe mit mehreren Werkzeugen @msg onto must name a panel floating in another group onto muss ein Bedienfeld nennen, das in einer anderen Gruppe schwebt +@msg below and above must name a panel floating in another group below und above müssen ein Bedienfeld nennen, das in einer anderen Gruppe schwebt +@msg give only one of onto, below and above nur eines von onto, below und above angeben +@msg column must be true or a number from 1 column muss true oder eine Zahl ab 1 sein @msg x and y must both be numbers x und y müssen beide Zahlen sein @msg "{0}" is not a colour: write it as #rrggbb "{0}" ist keine Farbe: Schreiben Sie sie als #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead dies ist kein Logo aus flächigen Farben ({_1} % seiner einfarbigen Bereiche entsprechen wenigen flächigen Farben), daher würde der Modus „Flaches Logo“ seine Verläufe in Flecken zerlegen: Verwenden Sie stattdessen den Modus „Farbe“ @@ -3842,6 +3854,7 @@ theme Appearance Erscheinungsbild ICF Bottom/Left ICF unten/links @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` ist der Schrittverweis {r}, den nur `run` und `command_batch` auflösen @msg `ids` must be an array of object ids, not {v} `ids` muss ein Array von Objekt-IDs sein, nicht {v} +@msg `corners` must be an array of anchor indices, not {v} `corners` muss ein Array von Ankerpunkt-Indizes sein, nicht {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: Das Ergebnis von Schritt {i} hat keinen solchen Wert (schreiben Sie $$ für ein literales $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: Vor diesem Schritt gibt es keinen Schritt {i} (Schritte zählen ab 0; schreiben Sie $$ für ein literales $) @msg object ids are non-negative integers, not {v} Objekt-IDs sind nicht negative ganze Zahlen, nicht {v} @@ -4019,3 +4032,59 @@ theme Appearance Erscheinungsbild @msg zone must be tab, center, left, right, top or bottom zone muss tab, center, left, right, top oder bottom sein @msg {key} must be a finite coordinate {key} muss eine endliche Koordinate sein @msg invalid restored panel group Ungültige wiederhergestellte Bedienfeldgruppe +# 3D and Materials › Revolve: its options dialog and gizmos. + Ambient Umgebungslicht + Axis side Achsenseite + Direction Richtung + Drag rings to rotate. Shift snaps to 15°. Ziehen Sie die Ringe, um zu drehen. Mit Shift in 15°-Schritten. + Drag the light, or its rings. Shift snaps to 15°. Ziehen Sie das Licht oder seine Ringe. Mit Shift in 15°-Schritten. + Drag values or labels. Double-click a value to type. Ziehen Sie Werte oder Beschriftungen. Doppelklicken Sie auf einen Wert, um ihn einzugeben. + Elevation Höhe + Intensity Intensität + Keep visible surfaces only when expanding Beim Umwandeln nur sichtbare Flächen behalten + Lighting Beleuchtung + Reset all Alle zurücksetzen + Reset all Revolve settings to their defaults. Alle Einstellungen für „Kreiseln“ auf ihre Standardwerte zurücksetzen. + Reset to default Auf Standard zurücksetzen + Revolve angle Kreiselwinkel + Rotation X Drehung X + Rotation Y Drehung Y + Rotation Z Drehung Z + Segments Segmente + Shading Schattierung + Show axis Achse einblenden + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Entfernt bei „Aussehen umwandeln“ verdeckte Geometrie. Die Live-Vorschau behält die ganze Oberfläche. Transparente Farben und Oberflächen jenseits der Sichtbarkeitsgrenzen behalten alle Flächen. + Accented Edges… Kanten betonen… + Angled Strokes… Schräge Striche… + Crosshatch… Kreuzschraffur… + Dark Strokes… Dunkle Striche… + Ink Outlines… Tintenumrisse… + Spatter… Spritzer… + Sprayed Strokes… Gesprühte Striche… + Sumi-e… Sumi-e… + Accented Edges Kanten betonen + Angled Strokes Schräge Striche + Crosshatch Kreuzschraffur + Dark Strokes Dunkle Striche + Ink Outlines Tintenumrisse + Spatter Spritzer + Sprayed Strokes Gesprühte Striche + Sumi-e Sumi-e + Direction Balance Richtungsbalance + Stroke Length Strichlänge + Sharpness Schärfe + Strength Stärke + Balance Balance + Black Intensity Schwarzintensität + White Intensity Weißintensität + Dark Intensity Dunkle Intensität + Light Intensity Lichtintensität + Spray Radius Sprühradius + Stroke Direction Strichrichtung + Stroke Width Strichbreite + Stroke Pressure Strichdruck + Right Diagonal Diagonal nach rechts + Left Diagonal Diagonal nach links +@msg invalid native panel set Ungültiger nativer Bedienfeldsatz +@msg missing panel layout Bedienfeldanordnung fehlt +@msg unsupported native docking operation Nicht unterstützte native Docking-Operation diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bc0ecfafd..dd147da78 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2051,6 +2051,7 @@ Proof Colors Colores de prueba Proof Setup Ajuste de prueba Properties Propiedades + Proportional Metrics Métrica proporcional Proportional Oldstyle Estilo antiguo proporcional Pucker & Bloat… Fruncir y engordar… Pucker Tool Herramienta Fruncir @@ -3317,6 +3318,7 @@ axis Horizontal Horizontal axis Vertical Vertical axis Both Ambos Drag to move Arrastrar para mover + Drag to move out of the set Arrastrar para sacar del conjunto Drag to float as a toolbar Arrastrar para flotar como barra de herramientas Put back in the toolbar Devolver a la barra de herramientas Float Panel Hacer flotar el panel @@ -3420,6 +3422,7 @@ axis Both Ambos @msg `color` must be a colour `color` debe ser un color @msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` debe ser un comando cuyo elemento de menú abra un cuadro de diálogo (consulta ui.menuDialog) @msg `freeform.points` must be an array `freeform.points` debe ser una lista +@msg `freeform.lines` must be an array `freeform.lines` debe ser una lista @msg `gradient` must be an object `gradient` debe ser un objeto @msg `library` is a library id or name (see {list}) `library` es el id o el nombre de una biblioteca (consulta {list}) @msg `mode` applies to freeform gradients `mode` se aplica a los degradados de forma libre @@ -3521,6 +3524,7 @@ axis Both Ambos @msg empty path trazado vacío @msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versión de archivo {0} es más reciente de lo que admite este VectorCraft ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` el punto de forma libre {i} necesita `at` [x, y] y un `color` válido +@msg freeform line {i} must be an array of point indices la línea de forma libre {i} debe ser una lista de índices de puntos @msg give both `start` and `end` [x, y] (or neither) indica `start` y `end` [x, y] (o ninguno de los dos) @msg give corners of the path: anchors between two straight sides indica esquinas del trazado: puntos de ancla entre dos lados rectos @msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura @@ -3759,6 +3763,8 @@ axis Both Ambos @msg the image's pixels don't match its size los píxeles de la imagen no coinciden con su tamaño @msg the layer has no objects to clip by la capa no tiene objetos con los que recortar @msg the layer or group is locked la capa o el grupo está bloqueado +@msg the guide is on a hidden or locked layer la guía está en una capa oculta o bloqueada +@msg the target layer is locked or hidden la capa de destino está bloqueada u oculta @msg the layer to flatten into is hidden la capa en la que acoplar está oculta @msg the object already has an opacity mask el objeto ya tiene una máscara de opacidad @msg the object has no geometry el objeto no tiene geometría @@ -3783,7 +3789,7 @@ axis Both Ambos @msg the {tool} tool has no options la herramienta {tool} no tiene opciones @msg there are no objects to release no hay objetos que soltar @msg there are no printer settings to open here aquí no hay ajustes de impresora que abrir -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it no se puede leer este archivo PostScript ({_1}): guárdalo como PDF o SVG en la aplicación que lo creó +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it no se puede leer este archivo PostScript ({why}): guárdalo como PDF o SVG en la aplicación que lo creó @msg this file's PostScript couldn't be read ({why}): {what} no se pudo leer el PostScript de este archivo ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens este formato admite una sola mesa de trabajo: exporta varias como PDF o con document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that este es un dibujo DWG, que no se puede leer: guárdalo desde la aplicación de CAD como DXF ASCII y abre ese archivo @@ -3872,6 +3878,9 @@ axis Both Ambos @msg tool (a tool id) is required se requiere tool (un id de herramienta) @msg {tool} isn't in a toolbar group of several tools {tool} no está en un grupo de la barra de herramientas con varias herramientas @msg onto must name a panel floating in another group onto debe nombrar un panel que flote en otro grupo +@msg below and above must name a panel floating in another group below y above deben nombrar un panel que flote en otro grupo +@msg give only one of onto, below and above indica solo uno de onto, below y above +@msg column must be true or a number from 1 column debe ser true o un número desde 1 @msg x and y must both be numbers x e y deben ser números @msg "{0}" is not a colour: write it as #rrggbb "{0}" no es un color: escríbelo como #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead esto no es un logotipo de colores planos ({_1}% de sus zonas sólidas coinciden con unos pocos colores planos), así que el modo Logotipo plano convertiría sus degradados en manchas: usa el modo Color @@ -3932,6 +3941,7 @@ axis Both Ambos Perspective Distort Distorsión de perspectiva @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` es la referencia a un paso {r}, que solo `run` y `command_batch` resuelven @msg `ids` must be an array of object ids, not {v} `ids` debe ser una lista de identificadores de objeto, no {v} +@msg `corners` must be an array of anchor indices, not {v} `corners` debe ser una lista de índices de puntos de ancla, no {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: el resultado del paso {i} no tiene ese valor (escriba $$ para un $ literal) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: no hay ningún paso {i} antes de este (los pasos se cuentan desde 0; escriba $$ para un $ literal) @msg object ids are non-negative integers, not {v} los identificadores de objeto son enteros no negativos, no {v} @@ -4062,3 +4072,64 @@ axis Both Ambos @msg {key} must be a finite coordinate {key} debe ser una coordenada finita @msg x and y must be provided together x e y deben proporcionarse juntos @msg invalid restored panel group El grupo de paneles restaurado no es válido + +# 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 + Vertical Guide Guía vertical + Horizontal Guide Guía horizontal + Move Guide to Layer Mover guía a capa +@msg invalid native panel set Conjunto nativo de paneles no válido +@msg missing panel layout Falta la distribución de paneles +@msg unsupported native docking operation Operación nativa de anclaje no admitida diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b0dd7b144..12d62d3ac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2048,6 +2048,7 @@ Proof Colors Couleurs d’épreuve Proof Setup Format d’épreuve Properties Propriétés + Proportional Metrics Métrique proportionnelle Proportional Oldstyle Elzévirien proportionnel Pucker & Bloat… Contracter et dilater… Pucker Tool Outil Contraction @@ -3314,6 +3315,7 @@ axis Horizontal Horizontal axis Vertical Vertical axis Both Les deux Drag to move Faire glisser pour déplacer + Drag to move out of the set Faire glisser pour sortir de l’ensemble Drag to float as a toolbar Faire glisser pour détacher en barre d’outils flottante Put back in the toolbar Remettre dans la barre d’outils Float Panel Rendre le panneau flottant @@ -3417,6 +3419,7 @@ axis Both Les deux @msg `color` must be a colour `color` doit être une couleur @msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` doit être une commande dont l’élément de menu ouvre une boîte de dialogue (voir ui.menuDialog) @msg `freeform.points` must be an array `freeform.points` doit être un tableau +@msg `freeform.lines` must be an array `freeform.lines` doit être un tableau @msg `gradient` must be an object `gradient` doit être un objet @msg `library` is a library id or name (see {list}) `library` est l’identifiant ou le nom d’une bibliothèque (voir {list}) @msg `mode` applies to freeform gradients `mode` s’applique aux dégradés de forme libre @@ -3518,6 +3521,7 @@ axis Both Les deux @msg empty path tracé vide @msg file version {0} is newer than this VectorCraft supports ({VERSION}) la version de fichier {0} est plus récente que celles que prend en charge cette version de VectorCraft ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` le point de forme libre {i} nécessite `at` [x, y] et une `color` valide +@msg freeform line {i} must be an array of point indices la ligne de forme libre {i} doit être un tableau d’indices de points @msg give both `start` and `end` [x, y] (or neither) indiquez à la fois `start` et `end` [x, y] (ou aucun des deux) @msg give corners of the path: anchors between two straight sides indiquez des angles du tracé : des points d’ancrage entre deux côtés droits @msg give the path `id` and the width point `index` indiquez l’`id` du tracé et l’`index` du point de largeur @@ -3756,6 +3760,8 @@ axis Both Les deux @msg the image's pixels don't match its size les pixels de l’image ne correspondent pas à sa taille @msg the layer has no objects to clip by le calque n’a aucun objet servant à l’écrêtage @msg the layer or group is locked le calque ou le groupe est verrouillé +@msg the guide is on a hidden or locked layer le repère se trouve sur un calque masqué ou verrouillé +@msg the target layer is locked or hidden le calque cible est verrouillé ou masqué @msg the layer to flatten into is hidden le calque de destination de l’aplatissement est masqué @msg the object already has an opacity mask l’objet a déjà un masque d’opacité @msg the object has no geometry l’objet n’a pas de géométrie @@ -3780,7 +3786,7 @@ axis Both Les deux @msg the {tool} tool has no options l’outil {tool} n’a pas d’options @msg there are no objects to release il n’y a aucun objet à libérer @msg there are no printer settings to open here il n’y a pas de réglages d’imprimante à ouvrir ici -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it impossible de lire ce fichier PostScript ({_1}) : enregistrez-le au format PDF ou SVG dans l’application qui l’a créé +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it impossible de lire ce fichier PostScript ({why}) : enregistrez-le au format PDF ou SVG dans l’application qui l’a créé @msg this file's PostScript couldn't be read ({why}): {what} impossible de lire le PostScript de ce fichier ({why}) : {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens ce format ne contient qu’un plan de travail : exportez-en plusieurs au format PDF ou avec document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that c’est un dessin DWG, qui ne peut pas être lu : enregistrez-le depuis l’application de CAO au format DXF ASCII et ouvrez ce fichier @@ -3869,6 +3875,9 @@ axis Both Les deux @msg tool (a tool id) is required tool (un identifiant d’outil) est obligatoire @msg {tool} isn't in a toolbar group of several tools {tool} ne fait pas partie d’un groupe de plusieurs outils dans la barre d’outils @msg onto must name a panel floating in another group onto doit désigner un panneau flottant dans un autre groupe +@msg below and above must name a panel floating in another group below et above doivent désigner un panneau flottant dans un autre groupe +@msg give only one of onto, below and above indiquez un seul de onto, below et above +@msg column must be true or a number from 1 column doit être true ou un nombre à partir de 1 @msg x and y must both be numbers x et y doivent tous deux être des nombres @msg "{0}" is not a colour: write it as #rrggbb « {0} » n’est pas une couleur : écrivez-la sous la forme #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ce n’est pas un logo en aplats ({_1} % de ses zones pleines correspondent à quelques couleurs unies), donc le mode Logo en aplats transformerait ses dégradés en taches : utilisez le mode Couleur @@ -3943,6 +3952,7 @@ axis Both Les deux ICF Bottom/Left ICF bas/gauche @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` est la référence d’étape {r}, que seuls `run` et `command_batch` résolvent @msg `ids` must be an array of object ids, not {v} `ids` doit être une liste d’identifiants d’objet, pas {v} +@msg `corners` must be an array of anchor indices, not {v} `corners` doit être une liste d’indices de points d’ancrage, pas {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}` : le résultat de l’étape {i} n’a pas cette valeur (écrivez $$ pour un $ littéral) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}` : il n’y a pas d’étape {i} avant celle-ci (les étapes sont comptées à partir de 0 ; écrivez $$ pour un $ littéral) @msg object ids are non-negative integers, not {v} les identifiants d’objet sont des entiers positifs ou nuls, pas {v} @@ -4074,3 +4084,62 @@ effect Soft Doux @msg {key} must be a finite coordinate {key} doit être une coordonnée finie @msg x and y must be provided together x et y doivent être fournis ensemble @msg invalid restored panel group Le groupe de panneaux restauré est invalide + +# 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 + Vertical Guide Repère vertical + Horizontal Guide Repère horizontal + Move Guide to Layer Déplacer le repère vers le calque +@msg invalid native panel set Ensemble natif de panneaux invalide +@msg missing panel layout Disposition des panneaux manquante +@msg unsupported native docking operation Opération native d’ancrage non prise en charge diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 940a2e985..e693ee6db 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2051,6 +2051,7 @@ Proof Colors Colori di prova Proof Setup Impostazione prova Properties Proprietà + Proportional Metrics Metrica proporzionale Proportional Oldstyle Proporzionali stile antico Pucker & Bloat… Contrai e gonfia… Pucker Tool Strumento Contrai @@ -3317,6 +3318,7 @@ axis Horizontal Orizzontale axis Vertical Verticale axis Both Entrambi Drag to move Trascina per spostare + Drag to move out of the set Trascina per spostare fuori dall’insieme Drag to float as a toolbar Trascina per rendere mobile come barra degli strumenti Put back in the toolbar Riporta nella barra degli strumenti Float Panel Rendi mobile pannello @@ -3420,6 +3422,7 @@ axis Both Entrambi @msg `color` must be a colour `color` deve essere un colore @msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` deve essere un comando la cui voce di menu apre una finestra di dialogo (vedi ui.menuDialog) @msg `freeform.points` must be an array `freeform.points` deve essere un array +@msg `freeform.lines` must be an array `freeform.lines` deve essere un array @msg `gradient` must be an object `gradient` deve essere un oggetto @msg `library` is a library id or name (see {list}) `library` è l'id o il nome di una libreria (vedi {list}) @msg `mode` applies to freeform gradients `mode` si applica alle sfumature a forma libera @@ -3521,6 +3524,7 @@ axis Both Entrambi @msg empty path tracciato vuoto @msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versione del file {0} è più recente di quelle supportate da questo VectorCraft ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` il punto a forma libera {i} richiede `at` [x, y] e un `color` valido +@msg freeform line {i} must be an array of point indices la linea a forma libera {i} deve essere un array di indici di punti @msg give both `start` and `end` [x, y] (or neither) specifica sia `start` sia `end` [x, y] (o nessuno dei due) @msg give corners of the path: anchors between two straight sides specifica angoli del tracciato: punti di ancoraggio tra due lati diritti @msg give the path `id` and the width point `index` specifica l'`id` del tracciato e l'`index` del punto di larghezza @@ -3759,6 +3763,8 @@ axis Both Entrambi @msg the image's pixels don't match its size i pixel dell'immagine non corrispondono alle sue dimensioni @msg the layer has no objects to clip by il livello non ha oggetti con cui ritagliare @msg the layer or group is locked il livello o il gruppo è bloccato +@msg the guide is on a hidden or locked layer la guida si trova su un livello nascosto o bloccato +@msg the target layer is locked or hidden il livello di destinazione è bloccato o nascosto @msg the layer to flatten into is hidden il livello in cui appiattire è nascosto @msg the object already has an opacity mask l'oggetto ha già una maschera di opacità @msg the object has no geometry l'oggetto non ha geometria @@ -3783,7 +3789,7 @@ axis Both Entrambi @msg the {tool} tool has no options lo strumento {tool} non ha opzioni @msg there are no objects to release non ci sono oggetti da rilasciare @msg there are no printer settings to open here non ci sono impostazioni della stampante da aprire qui -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it impossibile leggere questo file PostScript ({_1}): salvalo come PDF o SVG nell'app che lo ha creato +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it impossibile leggere questo file PostScript ({why}): salvalo come PDF o SVG nell'app che lo ha creato @msg this file's PostScript couldn't be read ({why}): {what} impossibile leggere il PostScript di questo file ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens questo formato contiene una sola tavola da disegno: esportane più di una come PDF o con document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that questo è un disegno DWG, che non può essere letto: salvalo dall'app CAD come DXF ASCII e apri quel file @@ -3872,6 +3878,9 @@ axis Both Entrambi @msg tool (a tool id) is required tool (un id di strumento) è obbligatorio @msg {tool} isn't in a toolbar group of several tools {tool} non fa parte di un gruppo di più strumenti nella barra degli strumenti @msg onto must name a panel floating in another group onto deve indicare un pannello mobile in un altro gruppo +@msg below and above must name a panel floating in another group below e above devono indicare un pannello mobile in un altro gruppo +@msg give only one of onto, below and above indica solo uno tra onto, below e above +@msg column must be true or a number from 1 column deve essere true o un numero da 1 @msg x and y must both be numbers x e y devono essere entrambi numeri @msg "{0}" is not a colour: write it as #rrggbb "{0}" non è un colore: scrivilo come #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead questo non è un logo a colori piatti ({_1}% delle sue aree piene corrisponde a pochi colori piatti), quindi la modalità Logo piatto trasformerebbe i suoi sfumati in macchie: usa la modalità Colore @@ -3932,6 +3941,7 @@ axis Both Entrambi Perspective Distort Distorsione prospettica @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` è il riferimento al passaggio {r}, che solo `run` e `command_batch` risolvono @msg `ids` must be an array of object ids, not {v} `ids` deve essere un elenco di ID oggetto, non {v} +@msg `corners` must be an array of anchor indices, not {v} `corners` deve essere un elenco di indici di punti di ancoraggio, non {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: il risultato del passaggio {i} non ha questo valore (scrivi $$ per un $ letterale) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: non c’è un passaggio {i} prima di questo (i passaggi si contano da 0; scrivi $$ per un $ letterale) @msg object ids are non-negative integers, not {v} gli ID oggetto sono interi non negativi, non {v} @@ -4064,3 +4074,64 @@ effect Regular Normale @msg {key} must be a finite coordinate {key} deve essere una coordinata finita @msg x and y must be provided together x e y devono essere forniti insieme @msg invalid restored panel group Il gruppo di pannelli ripristinato non è valido + +# 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 + Vertical Guide Guida verticale + Horizontal Guide Guida orizzontale + Move Guide to Layer Sposta guida nel livello +@msg invalid native panel set Gruppo nativo di pannelli non valido +@msg missing panel layout Disposizione dei pannelli mancante +@msg unsupported native docking operation Operazione nativa di ancoraggio non supportata diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 402031deb..9a1339899 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -37,6 +37,7 @@ Em Box Top/Right 仮想ボディの上 / 右 ICF Top/Right 平均字面の上 / 右 ICF Bottom/Left 平均字面の下 / 左 + Proportional Metrics プロポーショナルメトリクス Favorites お気に入り Flip 反転 Japanese 日本語 @@ -3265,6 +3266,7 @@ axis Horizontal 水平方向 axis Vertical 垂直方向 axis Both 両方 Drag to move 移動するにはドラッグ + Drag to move out of the set ドラッグしてセットから出す Drag to float as a toolbar ドラッグしてツールバーとして浮かせる Put back in the toolbar ツールバーに戻す Float Panel パネルをフローティング @@ -3469,9 +3471,11 @@ theme Appearance 外観 @msg `at` must be [x, y] `at` は [x, y] の形式で指定してください @msg `color` must be a colour `color` にはカラーを指定してください @msg `freeform.points` must be an array `freeform.points` には配列を指定してください +@msg `freeform.lines` must be an array `freeform.lines` には配列を指定してください @msg `{key}` must be a number `{key}` には数値を指定してください @msg a freeform gradient keeps at least one point フリーフォームグラデーションには少なくとも 1 つのポイントが必要です @msg freeform point {i} needs `at` [x, y] and a valid `color` フリーフォームのポイント {i} には `at` [x, y] と有効な `color` が必要です +@msg freeform line {i} must be an array of point indices フリーフォームのライン {i} にはポイントのインデックスの配列を指定してください @msg no point {i} (the gradient has {_1}) ポイント {i} はありません (このグラデーションのポイントは {_1} 個です) @msg no segment {segment} on line {line} ライン {line} にセグメント {segment} はありません @msg unknown freeform mode `{m}` (points, lines) `{m}` は不明なフリーフォームのモードです (points、lines) @@ -3497,6 +3501,8 @@ theme Appearance 外観 @msg no selection bounds 選択範囲に境界がありません @msg the layer has no objects to clip by レイヤーにクリッピングマスクとして使えるオブジェクトがありません @msg the layer or group is locked レイヤーまたはグループがロックされています +@msg the guide is on a hidden or locked layer ガイドが非表示またはロックされたレイヤー上にあります +@msg the target layer is locked or hidden ターゲットレイヤーがロックされているか非表示です @msg the layer to flatten into is hidden 統合先のレイヤーが非表示になっています @msg there are no objects to release レイヤーに分配するオブジェクトがありません @msg no library `{id}` ライブラリ `{id}` が見つかりません @@ -3633,7 +3639,7 @@ theme Appearance 外観 @msg shadings of an unknown type were left out 種類が不明なシェーディングは除外されました @msg shadings whose functions are of an unknown type are filled with their middle colour 関数の種類が不明なシェーディングは中間の色で塗りつぶされています @msg the file's preview header points outside the file ファイルのプレビューヘッダーがファイルの範囲外を指しています -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it この PostScript ファイルは読み込めません ({_1}): 作成したアプリで PDF または SVG として保存してください +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it この PostScript ファイルは読み込めません ({why}): 作成したアプリで PDF または SVG として保存してください @msg this is not a PostScript file PostScript ファイルではありません @msg a PostScript error ({name} in `{op}`) PostScript エラー (`{op}` での {name}) @msg a PostScript error ({name} in `{op}`), in {procs} {procs} 内の PostScript エラー (`{op}` での {name}) @@ -3795,6 +3801,9 @@ theme Appearance 外観 @msg give the path `id` and the width point `index` パスの `id` と幅ポイントの `index` を指定してください @msg no such width point 指定された幅ポイントが見つかりません @msg onto must name a panel floating in another group onto には、別のグループでフローティングしているパネルを指定してください +@msg below and above must name a panel floating in another group below と above には別のグループでフローティングしているパネルを指定してください +@msg give only one of onto, below and above onto、below、above のうち 1 つだけを指定してください +@msg column must be true or a number from 1 column には true または 1 以上の数値を指定してください @msg unknown panel `{raw}` `{raw}` は不明なパネルです @msg x and y must both be numbers x と y はどちらも数値で指定してください @msg Downloaded {what} {what} をダウンロードしました @@ -3925,6 +3934,7 @@ theme Appearance 外観 @msg every numbered name for it is taken in the Fonts folder Fonts フォルダーでは、このファイルの番号付きの名前がすべて使われています @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです @msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません) +@msg `corners` must be an array of anchor indices, not {v} `corners` はアンカーポイントのインデックスの配列にしてください ({v} は使えません) @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ステップ {i} の結果にその値はありません ($ そのものを書くには $$ と入力してください) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: この前にステップ {i} はありません (ステップは 0 から数えます。$ そのものを書くには $$ と入力してください) @msg object ids are non-negative integers, not {v} オブジェクト ID は 0 以上の整数です ({v} は使えません) @@ -4058,3 +4068,64 @@ effect Vertical 垂直 @msg zone must be a string zone は文字列にしてください @msg zone must be tab, center, left, right, top or bottom zone は tab、center、left、right、top、bottom のいずれかにしてください @msg {key} must be a finite coordinate {key} は有限の座標にしてください + +# 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 左斜め + Vertical Guide 垂直ガイド + Horizontal Guide 水平ガイド + Move Guide to Layer ガイドをレイヤーへ移動 +@msg invalid native panel set ネイティブパネルセットが無効です +@msg missing panel layout パネル配置がありません +@msg unsupported native docking operation ネイティブドッキング操作は未対応です diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0b7f987f4..e4befb686 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1721,6 +1721,7 @@ Proof Colors Provar Cores Proof Setup Configuração de Prova Properties Propriedades + Proportional Metrics Métrica Proporcional Pucker & Bloat… Encolher e Inchar… Pucker Tool Ferramenta Encolher Punctuation Space Espaço de Pontuação @@ -2697,6 +2698,7 @@ % of all commits % de todos os commits Lines +/− Linhas +/− Drag to move Arraste para mover + Drag to move out of the set Arraste para tirar do conjunto Drag to float as a toolbar Arraste para flutuar como barra de ferramentas Put back in the toolbar Devolver à barra de ferramentas Float Panel Flutuar Painel @@ -2762,3 +2764,40 @@ Group with Agrupar com Resize panels Redimensionar painéis Resize panel window Redimensionar janela do painel + +# 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… + Vertical Guide Guia vertical + Horizontal Guide Guia horizontal diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d4e9cc549..e805d0aff 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2047,6 +2047,7 @@ Proof Colors Цветопроба Proof Setup Настройка цветопробы Properties Свойства + Proportional Metrics Пропорциональные метрики Proportional Oldstyle Пропорциональные старого стиля Pucker & Bloat… Сморщивание и раздувание… Pucker Tool Инструмент «Сморщивание» @@ -3313,6 +3314,7 @@ axis Horizontal Горизонтально axis Vertical Вертикально axis Both Оба Drag to move Перетащите для перемещения + Drag to move out of the set Перетащите, чтобы вынести из набора Drag to float as a toolbar Перетащите, чтобы сделать плавающей панелью инструментов Put back in the toolbar Вернуть на панель инструментов Float Panel Сделать панель плавающей @@ -3416,6 +3418,7 @@ axis Both Оба @msg `color` must be a colour `color` должно быть цветом @msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` должно быть командой, пункт меню которой открывает диалоговое окно (см. ui.menuDialog) @msg `freeform.points` must be an array `freeform.points` должно быть массивом +@msg `freeform.lines` must be an array `freeform.lines` должно быть массивом @msg `gradient` must be an object `gradient` должно быть объектом @msg `library` is a library id or name (see {list}) `library` — это идентификатор или имя библиотеки (см. {list}) @msg `mode` applies to freeform gradients `mode` применяется к произвольным градиентам @@ -3517,6 +3520,7 @@ axis Both Оба @msg empty path пустой контур @msg file version {0} is newer than this VectorCraft supports ({VERSION}) версия файла {0} новее, чем поддерживает этот VectorCraft ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` произвольной точке {i} нужны `at` [x, y] и допустимый `color` +@msg freeform line {i} must be an array of point indices произвольная линия {i} должна быть массивом индексов точек @msg give both `start` and `end` [x, y] (or neither) укажите и `start`, и `end` [x, y] (или ни одного) @msg give corners of the path: anchors between two straight sides укажите углы контура: опорные точки между двумя прямыми сторонами @msg give the path `id` and the width point `index` укажите `id` контура и `index` точки ширины @@ -3755,6 +3759,8 @@ axis Both Оба @msg the image's pixels don't match its size пиксели изображения не соответствуют его размеру @msg the layer has no objects to clip by в слое нет объектов для обрезки @msg the layer or group is locked слой или группа заблокированы +@msg the guide is on a hidden or locked layer направляющая находится на скрытом или заблокированном слое +@msg the target layer is locked or hidden целевой слой заблокирован или скрыт @msg the layer to flatten into is hidden слой для объединения скрыт @msg the object already has an opacity mask у объекта уже есть непрозрачная маска @msg the object has no geometry у объекта нет геометрии @@ -3779,7 +3785,7 @@ axis Both Оба @msg the {tool} tool has no options инструмент {tool} не имеет параметров @msg there are no objects to release нет объектов для освобождения @msg there are no printer settings to open here здесь нет настроек принтера для открытия -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it этот файл PostScript невозможно прочитать ({_1}): сохраните его как PDF или SVG в приложении, которое его создало +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it этот файл PostScript невозможно прочитать ({why}): сохраните его как PDF или SVG в приложении, которое его создало @msg this file's PostScript couldn't be read ({why}): {what} PostScript этого файла невозможно прочитать ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens этот формат содержит одну монтажную область: экспортируйте несколько как PDF или с помощью document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that это чертёж DWG, который невозможно прочитать: сохраните его из приложения CAD как ASCII DXF и откройте его @@ -3868,6 +3874,9 @@ axis Both Оба @msg tool (a tool id) is required требуется tool (идентификатор инструмента) @msg {tool} isn't in a toolbar group of several tools {tool} не входит в группу панели инструментов из нескольких инструментов @msg onto must name a panel floating in another group onto должен называть панель, плавающую в другой группе +@msg below and above must name a panel floating in another group below и above должны называть панель, плавающую в другой группе +@msg give only one of onto, below and above укажите только один из onto, below и above +@msg column must be true or a number from 1 column должен быть true или числом от 1 @msg x and y must both be numbers x и y должны быть числами @msg "{0}" is not a colour: write it as #rrggbb «{0}» не является цветом: запишите его как #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead это не логотип из плоских цветов ({_1}% его сплошных областей совпадает с несколькими плоскими цветами), поэтому режим «Плоский логотип» превратил бы градиенты в пятна: используйте режим «Цвет» @@ -3930,6 +3939,7 @@ axis Both Оба Exit Выйти @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — это ссылка на шаг {r}, которую разрешают только `run` и `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` должен быть массивом идентификаторов объектов, а не {v} +@msg `corners` must be an array of anchor indices, not {v} `corners` должен быть массивом индексов опорных точек, а не {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: в результате шага {i} нет такого значения (чтобы написать сам $, введите $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед этим шагом нет шага {i} (шаги считаются с 0; чтобы написать сам $, введите $$) @msg object ids are non-negative integers, not {v} идентификаторы объектов — неотрицательные целые числа, а не {v} @@ -4060,3 +4070,64 @@ axis Both Оба @msg {key} must be a finite coordinate {key} должен быть конечной координатой @msg x and y must be provided together x и y необходимо указывать вместе @msg invalid restored panel group Восстановленная группа панелей недопустима + +# 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 Диагональ влево + Vertical Guide Вертикальная направляющая + Horizontal Guide Горизонтальная направляющая + Move Guide to Layer Переместить направляющую на слой +@msg invalid native panel set Недопустимый собственный набор панелей +@msg missing panel layout Отсутствует расположение панелей +@msg unsupported native docking operation Неподдерживаемая собственная операция прикрепления diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2f681dbf9..370f820e3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2043,6 +2043,7 @@ Proof Colors Пробні кольори Proof Setup Налаштування кольоропроби Properties Властивості + Proportional Metrics Пропорційні метрики Proportional Oldstyle Пропорційні цифри старого стилю Pucker & Bloat… Зморщування та роздування … Pucker Tool Інструмент «Зморщування» @@ -3305,6 +3306,7 @@ axis Horizontal Горизонтальна axis Vertical Вертикальна axis Both Обидві Drag to move Перетягніть для переміщення + Drag to move out of the set Перетягніть, щоб винести з набору Drag to float as a toolbar Перетягніть, щоб від’єднати панель інструментів Put back in the toolbar Повернути на панель інструментів Float Panel Від’єднати панель @@ -3403,6 +3405,7 @@ axis Both Обидві @msg `color` must be a colour `color` має бути кольором @msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` має бути командою, пункт меню якої відкриває діалогове вікно (див. ui.menuDialog) @msg `freeform.points` must be an array `freeform.points` має бути масивом +@msg `freeform.lines` must be an array `freeform.lines` має бути масивом @msg `gradient` must be an object `gradient` має бути об’єктом @msg `library` is a library id or name (see {list}) `library` — це ідентифікатор або ім’я бібліотеки (див. {list}) @msg `mode` applies to freeform gradients `mode` застосовується до довільних градієнтів @@ -3504,6 +3507,7 @@ axis Both Обидві @msg empty path порожній контур @msg file version {0} is newer than this VectorCraft supports ({VERSION}) версія файлу {0} новіша, ніж підтримує цей VectorCraft ({VERSION}) @msg freeform point {i} needs `at` [x, y] and a valid `color` довільній точці {i} потрібні `at` [x, y] та допустимий `color` +@msg freeform line {i} must be an array of point indices довільна лінія {i} має бути масивом індексів точок @msg give both `start` and `end` [x, y] (or neither) вкажіть `start`, `end` [x, y] (або жодного) @msg give corners of the path: anchors between two straight sides вкажіть кути контуру: опорні точки між двома прямими сторонами @msg give the path `id` and the width point `index` вкажіть `id` контуру та `index` точки ширини @@ -3742,6 +3746,8 @@ axis Both Обидві @msg the image's pixels don't match its size пікселі зображення не відповідають його розміру @msg the layer has no objects to clip by у шарі немає об’єктів для обрізки @msg the layer or group is locked шар чи група заблоковані +@msg the guide is on a hidden or locked layer напрямна розташована на прихованому або заблокованому шарі +@msg the target layer is locked or hidden цільовий шар заблокований або прихований @msg the layer to flatten into is hidden шар для об’єднання прихований @msg the object already has an opacity mask об’єкт вже має маску непрозорості. @msg the object has no geometry об’єкт не має геометрії @@ -3766,7 +3772,7 @@ axis Both Обидві @msg the {tool} tool has no options інструмент {tool} не має параметрів @msg there are no objects to release немає об’єктів для звільнення @msg there are no printer settings to open here тут немає налаштувань принтера для відкриття -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it цей файл PostScript неможливо прочитати ({_1}): збережіть його як PDF або SVG у додатку, який його створив +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it цей файл PostScript неможливо прочитати ({why}): збережіть його як PDF або SVG у додатку, який його створив @msg this file's PostScript couldn't be read ({why}): {what} PostScript цього файлу неможливо прочитати ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens цей формат містить одну монтажну область: експортуйте кілька як PDF або за допомогою document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that це креслення DWG, яке неможливо прочитати: збережіть його з CAD як ASCII DXF і відкрийте його @@ -3855,6 +3861,9 @@ axis Both Обидві @msg tool (a tool id) is required потрібно tool (ідентифікатор інструменту) @msg {tool} isn't in a toolbar group of several tools {tool} не входить до групи панелі інструментів з кількох інструментів @msg onto must name a panel floating in another group onto має називати панель, що плаває в іншій групі +@msg below and above must name a panel floating in another group below і above мають називати панель, що плаває в іншій групі +@msg give only one of onto, below and above вкажіть лише один із onto, below і above +@msg column must be true or a number from 1 column має бути true або числом від 1 @msg x and y must both be numbers x і y мають бути числами @msg "{0}" is not a colour: write it as #rrggbb "{0}" не є кольором: запишіть його як #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead це не логотип із плоских кольорів ({_1}% його суцільних областей збігається з кількома плоскими кольорами), тому режим «Плоский логотип» перетворив би градієнти на плями: використовуйте режим «Колір» @@ -3915,6 +3924,7 @@ axis Both Обидві Exit Вийти @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — це посилання на крок {r}, яке розв’язують лише `run` і `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` має бути масивом ідентифікаторів об’єктів, а не {v} +@msg `corners` must be an array of anchor indices, not {v} `corners` має бути масивом індексів опорних точок, а не {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: у результаті кроку {i} немає такого значення (щоб написати сам $, введіть $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед цим кроком немає кроку {i} (кроки рахуються з 0; щоб написати сам $, введіть $$) @msg object ids are non-negative integers, not {v} ідентифікатори об’єктів — невід’ємні цілі числа, а не {v} @@ -4045,3 +4055,62 @@ axis Both Обидві @msg {key} must be a finite coordinate {key} має бути скінченною координатою @msg x and y must be provided together x та y потрібно вказувати разом @msg invalid restored panel group Відновлена група панелей неприпустима + +# 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 Діагональ ліворуч + Vertical Guide Вертикальна напрямна + Horizontal Guide Горизонтальна напрямна + Move Guide to Layer Перемістити напрямну на шар +@msg invalid native panel set Неприпустимий власний набір панелей +@msg missing panel layout Відсутнє розташування панелей +@msg unsupported native docking operation Непідтримувана власна операція прикріплення diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6d3d74ed6..576ea5117 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1976,6 +1976,7 @@ Proof Colors 校样颜色 Proof Setup 校样设置 Properties 属性 + Proportional Metrics 比例度量 Proportional Oldstyle 比例旧式数字 Pucker & Bloat… 缩拢与膨胀… Pucker Tool 收缩工具 @@ -3517,6 +3518,7 @@ axis Horizontal 水平 axis Vertical 垂直 axis Both 两者 Drag to move 拖动以移动 + Drag to move out of the set 拖动以移出该组合 Drag to float as a toolbar 拖动使其浮动为工具栏 Put back in the toolbar 放回工具栏 Float Panel 浮动面板 @@ -3676,3 +3678,61 @@ effect Vertical 垂直 Group with 组合到 Resize panels 调整面板大小 Resize panel window 调整面板窗口大小 + +# 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 左对角线 + Vertical Guide 垂直参考线 + Horizontal Guide 水平参考线 + Move Guide to Layer 将参考线移至图层 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 60f0ecf68..45e536342 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2029,6 +2029,7 @@ Proof Colors 校樣色彩 Proof Setup 校樣設定 Properties 內容 + Proportional Metrics 比例度量 Proportional Oldstyle 比例舊式數字 Pucker & Bloat… 縮攏與膨脹… Pucker Tool 縮攏工具 @@ -3295,6 +3296,7 @@ axis Horizontal 水平 axis Vertical 垂直 axis Both 兩者 Drag to move 拖曳以移動 + Drag to move out of the set 拖曳以移出該組合 Drag to float as a toolbar 拖曳使其浮動為工具列 Put back in the toolbar 放回工具列 Float Panel 浮動面板 @@ -3457,3 +3459,61 @@ effect Soft 柔和 Group with 群組至 Resize panels 調整面板大小 Resize panel window 調整面板視窗大小 + +# 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 左對角線 + Vertical Guide 垂直參考線 + Horizontal Guide 水平參考線 + Move Guide to Layer 將參考線移至圖層 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bd4d9bd6f..bc89297ce 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -580,6 +580,16 @@ impl VectorcraftApp { return r; } let mut params = params; + // Paste in place, in front, in back (#693) and Select › All on Active Artboard (#1006): onto + // or on the active artboard, the view's. + if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack" | "select.allOnArtboard") + && params.get("artboard").is_none() + && self.view().is_some() + && let Some(n) = self.session.active().map(|d| d.doc.artboards.len()) + && let Some(p) = params.as_object_mut() + { + p.insert("artboard".into(), serde_json::json!(panels::artboards::selected(self, n))); + } if id.starts_with("edit.paste") { if let Err(e) = self.adopt_system_clipboard() { self.ui.status = e.clone(); @@ -593,14 +603,6 @@ impl VectorcraftApp { { p.insert("center".into(), serde_json::json!([c.x, c.y])); } - // In place, in front, in back: onto the active artboard (#693). - if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack") - && params.get("artboard").is_none() - && let Some(i) = self.view().map(|v| v.artboard) - && let Some(p) = params.as_object_mut() - { - p.insert("artboard".into(), serde_json::json!(i)); - } if let Some(r) = dialogs::swatch_conflict::ask(self, id, ¶ms) { return r; } @@ -787,6 +789,8 @@ impl VectorcraftApp { return; } let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY); + // The active document as the last look saw it: its uid and revision. + let (seen_key, doc) = (egui::Id::new("linkWatch.seen"), self.session.active().map(|d| (d.uid, d.revision))); if now - last >= 2.0 || (focused && !was_focused) { // Walking the document's links costs a pass over it: only when a look is due. // `start_link_scan` starts none for a document without links, which then doesn't @@ -794,12 +798,21 @@ impl VectorcraftApp { ctx.data_mut(|d| d.insert_temp(last_key, now)); self.session.start_link_scan(); let links = self.session.link_scan.is_some(); - ctx.data_mut(|d| d.insert_temp(egui::Id::new("linkWatch.links"), links)); + ctx.data_mut(|d| { + d.insert_temp(egui::Id::new("linkWatch.links"), links); + d.insert_temp(seen_key, doc); + }); } if self.session.link_scan.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } else if ctx.data(|d| d.get_temp::(egui::Id::new("linkWatch.links"))).unwrap_or(false) { ctx.request_repaint_after(std::time::Duration::from_millis(2000)); + } else if ctx.data(|d| d.get_temp::>(seen_key)).is_some_and(|seen| seen != doc) { + // The document changed (a file placed into one without links) or another came to the + // front since that look: wake for the next one even if the app sits idle until then, + // or a file placed and changed meanwhile is first seen after the change, which is then + // never acted on. + ctx.request_repaint_after(std::time::Duration::from_secs_f64((last + 2.0 - now).clamp(0.0, 2.0))); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a58f54082..74728ebe4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -274,13 +274,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel.float", "Float Panel", "", - "{panel: id or \"tools\", x?, y?, onto?: id, group?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it with the floating group holding that panel instead; `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved. \"tools\" floats the Tools panel. Returns {panel, floating, group, pos}", + "{panel: id or \"tools\", x?, y?, onto?: id, below?: id, above?: id, group?: bool, collapsed?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it as a tab with the floating group holding that panel instead; `below` / `above` puts its group in a set with that group, right below or above it, as dropping it on that group's lower or upper half does (the set's groups stack top to bottom and move together); `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved (out of its set, given x, y); `collapsed` shows its group's tabs only, as a double-click on a tab does; a docked group floats its dock column. \"tools\" floats the Tools panel. Returns {panel, floating, group, set, pos, collapsed, column}", ), ( "window.panel.dock", "Dock Panel", "", - "{panel: id or \"tools\", group?: bool} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; \"tools\" docks the Tools panel at the window's left edge", + "{panel: id or \"tools\", group?: bool, column?: true or n} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge. Returns {panel, floating, column}", ), ( "window.collapseDock", @@ -1143,6 +1143,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Vec { /// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the /// vector effects): (submenu, the catalogue's menu path of its effects). -const RASTER_MENUS: [(&str, &[&str]); 6] = [ +const RASTER_MENUS: [(&str, &[&str]); 7] = [ ("Blur", &["Effect", "Blur"]), + ("Brush Strokes", &["Effect", "Brush Strokes"]), ("Distort", &["Effect", "Distort"]), ("Pixelate", &["Effect", "Pixelate"]), ("Sharpen", &["Effect", "Sharpen"]), @@ -3382,6 +3384,10 @@ fn effect_menu() -> Vec { }; out.push(sub(sub_name, placeholder)); } else { + let mut items = items; + if sub_name == "3D and Materials" { + items.extend([todo("Extrude & Bevel…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")]); + } out.push(sub(sub_name, items)); } } diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 8efaf2c86..df21317ea 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -163,6 +163,28 @@ fn placement(params: &Value) -> Result, String> { } pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Option> { + if matches!(id, "window.panel.float" | "window.panel.dock") + && (params.get("above").is_some() + || params.get("below").is_some() + || params.get("column").is_some() + || params.get("collapsed").is_some() + || params + .get("onto") + .and_then(Value::as_str) + .and_then(normalize) + .and_then(|id| crate::floating::group_of(&app.ui, id)) + .and_then(|i| app.ui.floating_panels.get(i)) + .is_some_and(decorated) + || params + .get("panel") + .and_then(Value::as_str) + .and_then(normalize) + .and_then(|id| crate::floating::group_of(&app.ui, id)) + .and_then(|i| app.ui.floating_panels.get(i)) + .is_some_and(decorated)) + { + return Some(native_change(app, id, params)); + } if id == "window.panel.layout" { return Some( params @@ -244,6 +266,10 @@ fn apply_action(app: &mut VectorcraftApp, action: Action) -> Result float_fallback(&layout, panel), _ => None, }; + let activated = match &action { + Action::Activate { panel } => Some(panel.clone()), + _ => None, + }; layout.apply(action).map_err(|error| error.to_string())?; restore_float_fallback(&mut layout, fallback)?; if let Some((panel, location)) = returning { @@ -254,6 +280,13 @@ fn apply_action(app: &mut VectorcraftApp, action: Action) -> Result Result bool { app.ui.status = error; } if let Some(panel) = normalize(&drag.panel) - && let Some((_, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) + && let Some((target, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) { - crate::floating::highlight(ui.ctx(), rect); + crate::floating::preview(ui.ctx(), target, rect); } } else { if let Some(panel) = normalize(&drag.panel) @@ -544,6 +584,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { let (id, params) = match target { crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel":panel,"group":true})), crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"onto":onto})), + crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"below":onto})), + crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"above":onto})), + crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":true,"column":slot})), }; if let Err(error) = app.run(id, params) { app.ui.status = error; @@ -578,7 +621,10 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { let layout = layout.clone(); let t = crate::theme::Tokens::get(ui.ctx()); let collapsed = app.ui.dock_collapsed; - let display = if collapsed { Layout { root: None, floating: layout.floating.clone() } } else { layout.clone() }; + let mut display = if collapsed { Layout { root: None, floating: layout.floating.clone() } } else { layout.clone() }; + display + .floating + .retain(|group| !app.ui.floating_panels.iter().any(|native| decorated(native) && native.panels.iter().any(|id| group.panels.contains(id)))); let mut draw = |ui: &mut egui::Ui| { if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) { let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed})); @@ -643,37 +689,76 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { }; if let Some(panel) = output.group_drag.as_deref().and_then(normalize) && let Some(pos) = ui.input(|input| input.pointer.interact_pos()) - && let Some((_, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) + && let Some((target, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) { - crate::floating::highlight(ui.ctx(), rect); + crate::floating::preview(ui.ctx(), target, rect); } if let Some(error) = output.error { app.ui.status = error.to_string(); } + // Tab and icon tears finish as model actions; title moves finish as group_drop. + // Native sets are absent from the shared render tree, so their pointer target must win + // over a float proposal or a root target drawn behind the native window. + let tab_drop = ui.input(|input| input.pointer.primary_released().then(|| input.pointer.interact_pos()).flatten()).and_then(|pos| { + output.actions.iter().find_map(|action| { + let panel = match action { + Action::Float { panel, .. } | Action::Move { panel, .. } => normalize(panel)?, + _ => return None, + }; + crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) + .filter(|(target, _)| !matches!(target, crate::floating::Drop::Dock)) + .map(|(target, _)| (panel, target)) + }) + }); if ui.input(|input| input.pointer.any_released()) { - if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && cancel_legacy_drag(app, ui.ctx()) { + let native_drop = output.group_drop.as_ref().is_some_and(|(panel, pos)| { + normalize(panel).and_then(|id| crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(id), *pos)).is_some() + }); + if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) + && !native_drop + && tab_drop.is_none() + && cancel_legacy_drag(app, ui.ctx()) + { return app.ui.docking.is_some(); } ui.ctx().data_mut(|data| data.remove::(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"))); } for action in output.actions { + if tab_drop + .is_some_and(|(panel, _)| matches!(&action, Action::Float { panel: moved, .. } | Action::Move { panel: moved, .. } if moved == panel)) + { + continue; + } if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { app.ui.status = error; } } - if let Some((panel, pos)) = output.group_drop - && let Some(panel) = normalize(&panel) - && let Some((target, _)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) - { + let group_drop = output.group_drop.and_then(|(panel, pos)| { + let panel = normalize(&panel)?; + crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos).map(|(target, _)| (panel, target, true)) + }); + if let Some((panel, target, whole_group)) = group_drop.or_else(|| tab_drop.map(|(panel, target)| (panel, target, false))) { let (command, params) = match target { - crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": true})), - crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": true, "onto": onto})), + crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})), + crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})), + crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"below":onto})), + crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"above":onto})), + crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":whole_group,"column":slot})), }; - if let Err(error) = app.run(command, params) { - app.ui.status = error; + // Shared and native windows have already chosen their render ownership for this + // frame. Switch representations only after both renderers finish, so a panel's + // widgets never appear in two layers in the same frame. + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("vectorcraft-native-panel-drop"), (command, params))); + } + for group in &display.floating { + if let Some(first) = group.panels.first() + && let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(area.with(("floating", first)))) + { + ui.ctx().data_mut(|data| data.insert_temp(crate::floating::group_rect_id(first), rect)); } } icon_rail(app, ui); + crate::floating::docked_columns(app, ui); true } @@ -777,8 +862,89 @@ pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &st } // Keep the legacy inspection schema useful; shared layout remains the rendering authority. +pub(crate) fn decorated(group: &crate::state::FloatingPanels) -> bool { + group.column.is_some() || group.docked.is_some() || group.collapsed +} + +/// Keep the native set metadata alongside the validated shared ownership tree. Return locations +/// and root splits survive the mirror update; failed reconciliation restores the whole UI state. +pub(crate) fn reconcile_native(app: &mut VectorcraftApp, before: crate::state::UiState) -> Result<(), String> { + if before.docking.is_none() { + return Ok(()); + } + let desired = app.ui.floating_panels.clone(); + let result = (|| { + let prior = before.docking.as_ref().ok_or("missing panel layout")?; + app.ui.docking = Some(prior.clone()); + for group in &prior.floating { + for panel in &group.panels { + if !desired.iter().any(|g| g.panels.contains(panel)) { + change(app, "dock", &json!({"panel":panel}))?; + } + } + } + let mut layout = app.ui.docking.clone().ok_or("missing panel layout")?; + for group in &desired { + for panel in &group.panels { + if (Layout { root: layout.root.clone(), floating: vec![] }).contains(panel) { + if let Ok(location) = layout.location(panel) { + app.ui.docking_hidden.insert(panel.clone(), location); + } + layout.apply(Action::Close { panel: panel.clone() }).map_err(|e| e.to_string())?; + } + } + } + layout.floating = desired + .iter() + .map(|group| { + let height = prior.floating.iter().find(|g| g.panels.iter().any(|id| group.panels.contains(id))).map_or(400.0, |g| g.rect[3]); + Floating { + panels: group.panels.clone(), + active: group.active, + rect: [group.pos[0], group.pos[1], group.width.unwrap_or(300.0), height], + } + }) + .collect(); + if !valid(&layout) { + return Err("invalid native panel set".into()); + } + app.ui.docking = Some(layout); + app.ui.floating_panels = desired; + Ok(()) + })(); + if result.is_err() { + app.ui = before; + } + result +} + +fn native_change(app: &mut VectorcraftApp, id: &str, params: &Value) -> Result { + let before = app.ui.clone(); + let result = (|| { + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; + if crate::floating::group_of(&app.ui, panel).is_none() { + let mut initial = params.clone(); + if let Some(object) = initial.as_object_mut() { + for key in ["above", "below", "column", "collapsed", "onto"] { + object.remove(key); + } + } + change(app, "float", &initial)?; + } + let baseline = app.ui.clone(); + let value = crate::floating::command(app, id, params).ok_or("unsupported native docking operation")??; + reconcile_native(app, baseline)?; + Ok(value) + })(); + if result.is_err() { + app.ui = before; + } + result +} + fn sync_legacy(app: &mut VectorcraftApp) { if let Some(layout) = &app.ui.docking { + let old = app.ui.floating_panels.clone(); let mut pending: Vec<_> = layout.root.iter().collect(); while let Some(node) = pending.pop() { match node { @@ -799,6 +965,12 @@ fn sync_legacy(app: &mut VectorcraftApp) { active: group.active, pos: [group.rect[0], group.rect[1]], width: Some(group.rect[2]), + column: old.iter().find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))).and_then(|g| g.column), + collapsed: old + .iter() + .find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))) + .is_some_and(|g| g.collapsed), + docked: old.iter().find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))).and_then(|g| g.docked), }) .collect(); } @@ -807,3 +979,13 @@ fn sync_legacy(app: &mut VectorcraftApp) { #[cfg(test)] #[path = "panel_docking_tests.rs"] mod tests; + +/// Commit a pointer drop after shared and native panel windows have finished rendering. +pub(crate) fn finish_native_drop(app: &mut VectorcraftApp, ctx: &egui::Context) { + if let Some((command, params)) = + ctx.data_mut(|data| data.remove_temp::<(&'static str, serde_json::Value)>(egui::Id::new("vectorcraft-native-panel-drop"))) + && let Err(error) = app.run(command, params) + { + app.ui.status = error; + } +} diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 5013a0483..5f1bdca43 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -59,8 +59,10 @@ fn harness(app: VectorcraftApp, size: egui::Vec2) -> Harness<'static, Vectorcraf ready = true; return; } + crate::floating::track(app, ui.ctx()); crate::dock::show(app, ui); egui::CentralPanel::default().show(ui, |_| {}); + crate::floating::show(app, ui.ctx()); }, app, ); @@ -415,3 +417,159 @@ fn iconic_origin_tracks_explicit_custom_dock_and_legacy_saved_floats() { assert!(!legacy.ui.docking.as_ref().unwrap().contains(&"color".into()), "legacy icon floats return to rail"); assert_eq!(legacy.ui.open_panel.as_deref(), Some("color")); } + +#[test] +fn native_sets_columns_and_collapse_preserve_shared_splits_and_saved_origins() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + let original = app.ui.docking.clone().unwrap(); + app.run("window.panel.float", json!({"panel":"stroke","x":100,"y":100})).unwrap(); + app.run("window.panel.float", json!({"panel":"color","below":"stroke","collapsed":true})).unwrap(); + let root = app.ui.docking.as_ref().unwrap().root.clone(); + assert_eq!(root, original.root); + let column = app.ui.floating_panels.first().unwrap().column; + assert!(column.is_some()); + assert_eq!(app.ui.floating_panels.len(), 2); + assert!(app.ui.floating_panels.iter().all(|g| g.column == column)); + app.run("window.panel.dock", json!({"panel":"stroke","group":true,"column":true})).unwrap(); + assert!(app.ui.floating_panels.iter().all(|g| g.docked == Some(1))); + app.run("window.workspace.new", json!({"name":"Mixed shared native"})).unwrap(); + let saved = app.ui.floating_panels.clone(); + app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); + app.run("window.workspace", json!({"name":"Mixed shared native"})).unwrap(); + assert_eq!(app.ui.floating_panels, saved); + assert_eq!(app.ui.docking.as_ref().unwrap().root, root); + app.run("window.panel.float", json!({"panel":"stroke","group":true})).unwrap(); + assert!(app.ui.floating_panels.iter().all(|g| g.docked.is_none())); + let before = serde_json::to_value(&app.ui).unwrap(); + assert!(app.run("window.panel.float", json!({"panel":"stroke","below":"unknown"})).is_err()); + // Error reporting may change status, but ownership, metadata and root remain atomic. + let mut after = serde_json::to_value(&app.ui).unwrap(); + let mut before = before; + before.as_object_mut().unwrap().remove("status"); + after.as_object_mut().unwrap().remove("status"); + assert_eq!(before, after); + app.run("window.panel.dock", json!({"panel":"stroke","group":true})).unwrap(); + app.run("window.panel.dock", json!({"panel":"color","group":true})).unwrap(); + assert_eq!(app.ui.docking.as_ref().unwrap().root, original.root); + assert!(app.ui.floating_panels.is_empty()); + assert!(valid(app.ui.docking.as_ref().unwrap())); +} + +#[test] +fn native_set_close_pointer_restores_shared_split_ownership() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + let root = app.ui.docking.as_ref().unwrap().root.clone(); + app.run("window.panel.float", json!({"panel":"stroke","x":70,"y":90})).unwrap(); + app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 900.0)); + if let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_SNAPSHOT_DIR") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(directory.join("vectorcraft-native-set-shared-split.png")).unwrap(); + } + let area = h.ctx.memory(|m| m.area_rect(crate::floating::area_id("stroke"))).unwrap(); + let at = egui::pos2(area.right() - 10.0, area.top() + 1.0 + crate::floating::TITLE / 2.0); + for pressed in [true, false] { + h.event(Event::PointerMoved(at)); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(3); + assert!(h.state().ui.floating_panels.is_empty()); + assert_eq!(h.state().ui.docking.as_ref().unwrap().root, root); + assert!(valid(h.state().ui.docking.as_ref().unwrap())); +} + +#[test] +fn a_single_native_tab_redock_retains_the_remaining_float_and_return_origin() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + let root = app.ui.docking.as_ref().unwrap().root.clone(); + app.run("window.panel.float", json!({"panel":"swatches","x":80,"y":90})).unwrap(); + app.run("window.panel.float", json!({"panel":"color","onto":"swatches","collapsed":true})).unwrap(); + assert!( + matches!(app.ui.docking.as_ref().unwrap().root.as_ref(), Some(Node::Tabs { panels, active }) if panels.get(*active).is_some_and(|panel| panel == "properties")), + "floating Color intentionally activates the first root fallback, Properties" + ); + let mut expected = Layout { root, floating: vec![] }; + expected.apply(Action::Activate { panel: "properties".to_string() }).unwrap(); + app.run("window.panel.dock", json!({"panel":"swatches"})).unwrap(); + assert_eq!(app.ui.docking.as_ref().unwrap().root, expected.root); + assert_eq!(app.ui.floating_panels.len(), 1); + assert_eq!(app.ui.floating_panels[0].panels, ["color"]); + assert!(valid(app.ui.docking.as_ref().unwrap())); +} + +#[test] +fn explicit_shared_activation_expands_a_native_collapsed_owner() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"layers","collapsed":true})).unwrap(); + assert!(app.ui.floating_panels[0].collapsed); + app.run("window.panel.layout", json!({"action":Action::Activate { panel:"layers".to_string() }})).unwrap(); + assert!(!app.ui.floating_panels[0].collapsed); + assert_eq!(app.ui.floating_panels[0].panels, ["layers"]); + assert!(valid(app.ui.docking.as_ref().unwrap())); +} + +#[test] +fn escape_restores_native_set_position_and_metadata_after_several_frames() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"stroke","x":80,"y":100})).unwrap(); + app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 900.0)); + let original = h.state().ui.floating_panels.clone(); + let layout = h.state().ui.docking.clone(); + let area = h.ctx.memory(|m| m.area_rect(crate::floating::area_id("stroke"))).unwrap(); + let from = area.left_top() + vec2(30.0, 1.0 + crate::floating::TITLE / 2.0); + h.event(Event::PointerMoved(from)); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for step in 1..=8 { + h.event(Event::PointerMoved(from + vec2(step as f32 * 12.0, step as f32 * 9.0))); + h.run_steps(1); + } + assert_ne!(h.state().ui.floating_panels, original, "actual title gesture moved the set"); + h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.floating_panels, original); + assert_eq!(h.state().ui.docking, layout); +} + +#[test] +fn first_legacy_icon_tear_can_join_a_native_set() { + let mut app = app(); + crate::floating::float(&mut app.ui, &["stroke"], "stroke", egui::pos2(100.0, 100.0)); + crate::floating::float(&mut app.ui, &["color"], "color", egui::pos2(100.0, 100.0)); + crate::floating::attach(&mut app.ui, &[1], "stroke", true); + assert!(app.ui.docking.is_none()); + let mut h = harness(app, vec2(1280.0, 900.0)); + let source = h.query_all_by_label("Swatches").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + let color = h.ctx.data(|data| data.get_temp::(crate::floating::group_rect_id("color"))).unwrap(); + drag(&mut h, source, egui::pos2(color.center().x, color.bottom() + 2.0)); + assert!(h.state().ui.docking.is_some(), "successful first tear retains shared ownership"); + let groups = &h.state().ui.floating_panels; + let color = groups.iter().find(|g| g.panels.iter().any(|p| p == "color")).unwrap(); + let swatches = groups.iter().find(|g| g.panels.iter().any(|p| p == "swatches")).unwrap(); + assert!(color.column.is_some()); + assert_eq!(swatches.column, color.column); + assert!(valid(h.state().ui.docking.as_ref().unwrap())); +} + +#[test] +fn native_set_drop_targets_respect_the_visible_layer_above_an_ordinary_float() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"swatches","x":100,"y":100})).unwrap(); + app.run("window.panel.float", json!({"panel":"stroke","x":100,"y":100})).unwrap(); + app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap(); + let h = harness(app, vec2(1280.0, 900.0)); + let rect = h.ctx.data(|data| data.get_temp::(crate::floating::group_rect_id("stroke"))).unwrap(); + let at = rect.center(); + assert_eq!(h.ctx.layer_id_at(at).unwrap().id, crate::floating::area_id("stroke"), "native set is visibly above the ordinary shared float"); + let target = crate::floating::drop_target(h.state(), &h.ctx, crate::floating::Moving::Panel("layers"), at).unwrap().0; + assert!( + matches!(target, crate::floating::Drop::Above("stroke") | crate::floating::Drop::Below("stroke")), + "visible native group owns the drop: {target:?}" + ); +} diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index db3be6885..ab99fbaa6 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -235,6 +235,26 @@ mod tests { crate::menus::invoke(&mut app, "view.goToArtboard", json!({"index": "previous"})); assert_eq!(selected(&app, 2), 0, "and the panel follows the navigator"); } + + /// #1006: Select › All on Active Artboard selects the art of the artboard made active here, + /// and an explicit artboard still wins. + #[test] + fn all_on_active_artboard_takes_the_active_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 2})).unwrap(); + let mut rect = |x| app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + let (a, b) = (rect(10), rect(130)); + let picked = |app: &mut VectorcraftApp, params| { + crate::menus::invoke(app, "select.allOnArtboard", params); + let ids: Vec = app.session.active().unwrap().selection.objects.iter().map(|n| n.0).collect(); + json!(ids) + }; + select(&mut app, 1); + assert_eq!(picked(&mut app, json!({})), json!([b])); + select(&mut app, 0); + assert_eq!(picked(&mut app, json!({})), json!([a])); + assert_eq!(picked(&mut app, json!({"artboard": 1})), json!([b])); + } /// Double-clicking a row's number goes to its artboard and its name renames it; a row's /// Options button edits that artboard with the Artboard tool, and the tool's artboard is the /// active one. diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 9405ab8fe..a255d8ca2 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{CharStyle, NodeId, NodeKind}; use vectorcraft_tools::{Mods, ToolKey}; -use super::{first_selected, pstate, set_pstate}; +use super::{pstate, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; use crate::widgets::{self, SpinPick, menu_item}; @@ -20,26 +20,62 @@ pub const SIZE_PRESETS: [f64; 15] = [6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, pub const TRACKING_PRESETS: [f64; 14] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0]; pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0]; -/// The character style shown by the panel: the selected range's (or the caret's) while the Type -/// tool edits text, else the first selected text object's first run. With it, the paragraph +/// The stored character style: the selected range's (or the caret's) while the Type +/// tool edits text, else the first selected text object's first run (including groups). With it, the paragraph /// attributes of the paragraph the selection starts in (else of the first paragraph). +/// Font/paragraph controls and named-style capture use this; dimensional fields use `shared_effective`. pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft_doc::ParaStyle)> { if let Some((id, a, b)) = text_editing(app) && let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind) { return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para_at(t.paragraphs_in(a, a).start).clone())); } - let n = first_selected(app)?; - match &n.kind { - NodeKind::Text(t) => Some((t.first_style(), t.para_at(0).clone())), - _ => None, + let t = first_selected_text(app)?; + Some((t.first_style(), t.para_at(0).clone())) +} + +/// The first selected text descendant, shared by Character and Paragraph controls. +pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_doc::TextObject> { + let st = app.session.active()?; + for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + let mut text = None; + node.walk(&mut |n| { + if text.is_none() + && let NodeKind::Text(t) = &n.kind + { + text = Some(t.as_ref()); + } + }); + if text.is_some() { + return text; + } } + None } /// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the /// caret's style at a caret), else every run of the selected type, groups' included. `None` (a /// blank field) where they differ, or with no type. pub(crate) fn shared(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option { + shared_styles(app, |_, style| Some(f(style))) +} + +/// Dimensional controls use document units and tolerate arithmetic roundoff when comparing +/// compensated runs. Keep raw styles available to font/paragraph controls and style definitions, +/// even if a collapsed affine prevents editing dimensions. +fn shared_effective(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> f64) -> Option { + shared_styles(app, |t, style| t.effective_char_style(style).map(|s| Metric(f(&s)))).map(|v| v.0) +} + +struct Metric(f64); + +impl PartialEq for Metric { + fn eq(&self, other: &Self) -> bool { + (self.0 - other.0).abs() <= 1e-9 * self.0.abs().max(other.0.abs()).max(1.0) + } +} + +fn shared_styles(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc::TextObject, &CharStyle) -> Option) -> Option { let st = app.session.active()?; let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) { Some(NodeKind::Text(t)) => Some(t), @@ -49,26 +85,26 @@ pub(crate) fn shared(app: &VectorcraftApp, f: impl Fn(&CharStyle) if let Some((id, a, b)) = text_editing(app) { let t = text(id)?; if b <= a { - return Some(f(&vectorcraft_text::edit::insertion_style(&t.runs, a, b))); + return f(t, &vectorcraft_text::edit::insertion_style(&t.runs, a, b)); } let mut end = 0; for r in &t.runs { let start = end; end += r.text.len(); if start < b && end > a { - acc.add(f(&r.style)); + acc.add(f(t, &r.style)); } } } else { for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { n.walk(&mut |c| { if let NodeKind::Text(t) = &c.kind { - t.runs.iter().for_each(|r| acc.add(f(&r.style))); + t.runs.iter().for_each(|r| acc.add(f(t, &r.style))); } }); } } - acc.value() + acc.value().flatten() } /// The value a run of styles share so far ([`shared`]). @@ -402,7 +438,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & egui::Grid::new(id).num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| { cell(ui, "T", tl!("Font Size"), |ui| size_field(app, ui, "ch-size", fw)); cell(ui, "A↕", tl!("Leading"), |ui| { - let leading = shared(app, CharStyle::effective_leading); + let leading = shared_effective(app, CharStyle::effective_leading); let auto = shared(app, |s| s.leading.is_none()) == Some(true); match widgets::spin_field_auto(ui, "ch-lead", leading, Some(auto), unit, fw, 1.0, 0.1, &SIZE_PRESETS) { Some(SpinPick::Value(v)) => style(app, json!({"leading": v})), @@ -427,18 +463,18 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & return; } cell(ui, "IT", tl!("Vertical Scale %"), |ui| { - if let Some(v) = widgets::spin_plain(ui, "ch-vs", s.v_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { + if let Some(v) = widgets::spin_plain(ui, "ch-vs", shared(app, |s| s.v_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { format(app, json!({"vScale": v})); } }); cell(ui, "T↔", tl!("Horizontal Scale %"), |ui| { - if let Some(v) = widgets::spin_plain(ui, "ch-hs", s.h_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { + if let Some(v) = widgets::spin_plain(ui, "ch-hs", shared_effective(app, |s| s.h_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { format(app, json!({"hScale": v})); } }); ui.end_row(); cell(ui, "Aª", tl!("Baseline Shift"), |ui| { - if let Some(v) = widgets::spin_field(ui, "ch-bs", Some(s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "ch-bs", shared_effective(app, |s| s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) { format(app, json!({"baselineShift": v})); } }); @@ -454,7 +490,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & /// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or /// picked from the presets, in the type unit, blank where the selected text's sizes differ. pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) { - let size = shared(app, |s| s.size); + let size = shared_effective(app, |s| s.size); if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) { style(app, json!({"size": v})); } @@ -619,6 +655,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // Proportional Metrics: full-width glyphs on the font's proportional widths (`palt`). + let proportional = s.as_ref().is_some_and(|s| s.proportional_metrics); + if menu_item(ui, tl!("Proportional Metrics"), has, proportional) { + format(app, json!({"proportionalMetrics": !proportional})); + } ui.separator(); } for l in ["Standard Vertical Roman Alignment", "Tate-chu-yoko", "Fractional Widths", "System Layout", "No Break"] { @@ -646,6 +687,81 @@ mod tests { use super::*; use vectorcraft_engine::Session; + #[test] + fn scaled_dimensions_do_not_show_false_mixed_values_from_roundoff() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone(); + app.run("object.scale", json!({"sx": 120, "sy": 110})).unwrap(); + let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("text.setStyle", json!({"size": 7, "leading": 9})).unwrap(); + app.run("text.setFormat", json!({"hScale": 130, "baselineShift": 3})).unwrap(); + for (actual, expected) in [ + (shared_effective(&app, |s| s.size), 7.0), + (shared_effective(&app, CharStyle::effective_leading), 9.0), + (shared_effective(&app, |s| s.h_scale), 130.0), + (shared_effective(&app, |s| s.baseline_shift), 3.0), + ] { + assert!((actual.expect("equal dimensions must not be mixed") - expected).abs() < 1e-8); + } + } + + #[test] + fn scaled_collapsed_text_keeps_nondimensional_controls_available() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap(); + app.run("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap(); + assert!(text_style(&app).is_some()); + assert_eq!(shared(&app, |s| s.tracking), Some(0.0)); + assert_eq!(shared_effective(&app, |s| s.size), None); + app.run("text.setStyle", json!({"tracking": 40})).unwrap(); + app.run("text.setFormat", json!({"leftIndent": 4})).unwrap(); + assert_eq!(shared(&app, |s| s.tracking), Some(40.0)); + assert_eq!(text_style(&app).unwrap().1.left_indent, 4.0); + let ctx = egui::Context::default(); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + assert!(!texts.iter().any(|(s, _)| s == "No text selected")); + } + + #[test] + fn scaled_character_fields_show_document_sizes_and_mixed_group_values() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone(); + app.run("object.scale", json!({"sx": 200})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.size), Some(24.0)); + assert_eq!(text_style(&app).unwrap().0.size, 12.0, "style definitions keep stored attributes"); + let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("object.group", json!({})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.size), Some(24.0), "different local sizes, same displayed size"); + app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.h_scale), Some(200.0)); + app.run("text.setStyle", json!({"size": 10})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.size), Some(10.0)); + app.run("text.setFormat", json!({"hScale": 100})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.h_scale), Some(100.0)); + } + + #[test] + fn scaled_range_fields_follow_the_type_tool_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis label", "size": 12})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(text_editing(&app).is_some()); + app.run("object.scale", json!({"sx": 200})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 4, "size": 10})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_effective(&app, |s| s.size), None); + app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); + assert_eq!(shared_effective(&app, |s| s.size), Some(10.0)); + assert_eq!(text_style(&app).unwrap().0.size, 5.0); + } + /// Bold and Italic pick the family's own faces by name, and toggle back (#724). #[test] fn bold_and_italic_pick_the_familys_own_faces() { @@ -673,17 +789,20 @@ mod tests { assert!(!crate::menus::enabled(&app, "type.bold")); } - /// Character Alignment is in the panel menu with the East Asian options only (as Mojikumi Set - /// and Top-to-Top Leading are in the Paragraph panel). + /// Character Alignment and Proportional Metrics are in the panel menu with the East Asian + /// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel). #[test] fn character_alignment_shows_with_the_east_asian_options() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "雅楽"})).unwrap()["id"].clone(); app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Alignment"); - assert!(!shown(&mut app)); + let shown = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, menu); + (text.contains("Character Alignment"), text.contains("Proportional Metrics")) + }; + assert_eq!(shown(&mut app), (false, false)); app.session.prefs.show_east_asian_options = true; - assert!(shown(&mut app)); + assert_eq!(shown(&mut app), (true, true)); } } diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 87c6a0fc9..8cc08516a 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -98,6 +98,9 @@ enum LayersDrag { Rows(Vec), /// The selected-art square: the selected objects go to the row it is dropped on. Art, + /// Ruler guide rows (indexes into the document's guides): they go to the layer of the row + /// they are dropped on. + Guides(Vec), } /// A drag down the eye or lock column: every row it passes takes `value`. @@ -220,6 +223,8 @@ struct Row { colour: Color32, /// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer. dim: bool, + /// A ruler guide on the layer `node` (its index in the document's guides): the guide's row. + guide: Option, } /// What the rows were listed from: listed again when any of it changes. @@ -239,7 +244,7 @@ fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str) let mask_layer = doc.mask_edit.map(|m| m.layer); let mut out = vec![]; for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { - push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query), &mut out); + push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, doc), &mut out); } out } @@ -251,10 +256,10 @@ fn push_rows( depth: usize, clip_path: bool, (colour, shown): (Color32, bool), - cx: (&OpenRows, &PanelOptions, &str), + cx: (&OpenRows, &PanelOptions, &str, &Document), out: &mut Vec, ) { - let (open_rows, opts, query) = cx; + let (open_rows, opts, query, doc) = cx; if opts.layers_only && !n.is_layer() { return; } @@ -266,11 +271,18 @@ fn push_rows( _ => colour, }; let shown = shown && n.visible; - let opens = opens(n, opts); let searching = !query.is_empty(); + // A layer's ruler guides are rows in it, above its art (not while searching). + let guides: Vec = if n.is_layer() && !opts.layers_only && !searching { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] }; + let opens = opens(n, opts) || !guides.is_empty(); let open = opens && (searching || open_rows.contains(n.id)); let at = out.len(); - out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template() }); + out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template(), guide: None }); + if open { + for i in guides.into_iter().rev() { + out.push(Row { node: n.clone(), depth: depth + 1, clip_path: false, opens: false, open: false, colour, dim: !shown, guide: Some(i) }); + } + } if open && let Some(children) = n.children() { for (i, c) in children.iter().enumerate().rev() { push_rows(c, depth + 1, i == 0 && n.clips(), (colour, shown), cx, out); @@ -302,6 +314,8 @@ fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions, struct View<'a> { doc: &'a Document, sel: HashSet, + /// The selected ruler guides. + guides: Vec, target: Option, current: Option, rows: Vec, @@ -362,6 +376,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let view = View { doc: &doc, sel: st.selection.objects.iter().copied().collect(), + guides: st.selection.guides.clone(), target: st.selection.target, current: st.current_layer(), rows, @@ -537,13 +552,77 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { NodeKind::Group { .. } => match drag { LayersDrag::Rows(ids) => !ids.iter().any(|i| doc.node(*i).is_some_and(Node::is_layer)), LayersDrag::Art => true, + LayersDrag::Guides(_) => false, }, _ => false, } } +/// The row of ruler guide `i` on the layer of `item`: a guide mark and its name, the selection +/// square when it is selected. A click selects the guide; Shift or Cmd adds it to (or takes it +/// from) the selected guides. A guide on a hidden or locked layer can't be selected or dragged. +fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { + let (t, h) = (&view.t, view.h); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); + let Some(g) = view.doc.guides.get(i) else { return }; + let selected = view.guides.contains(&i); + let editable = view.doc.guide_editable(g); + if editable && resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); + } + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); + let mut x = r.left() + 2.0 * COLUMN + 2.0; + colour_bar(ui, &item.node, r, x, item.colour); + x += 6.0 + item.depth as f32 * INDENT + 16.0; + // The guide mark: a short line across or down, in the guides' colour. + let c = egui::pos2(x + 8.0, r.center().y); + let half = if g.vertical { vec2(0.0, 6.0) } else { vec2(6.0, 0.0) }; + ui.painter().line_segment([c - half, c + half], Stroke::new(1.5, t.accent)); + x += 22.0; + let name = format!("<{}>", if g.vertical { tl!("Vertical Guide") } else { tl!("Horizontal Guide") }); + let color = if item.dim { t.text_dim } else { t.text }; + ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), color); + let col_x = r.right() - 43.5; + ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); + if selected { + let q = egui::Rect::from_center_size(egui::pos2(r.right() - SQUARE_X, r.center().y), vec2(7.0, 7.0)); + ui.painter().rect_filled(q, 0.0, item.colour); + ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside); + } + if editable && resp.clicked() { + let m = ui.input(|i| i.modifiers); + out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command}))); + } + // Dragged: the selected guides when this is one of them, else this one, to another layer's row. + if editable && resp.drag_started() { + let guides = if selected { view.guides.clone() } else { vec![i] }; + egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides)); + } + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + // Guides dropped here go to this guide's layer. + if let Some(drag) = resp.dnd_hover_payload::() + && let LayersDrag::Guides(guides) = &*drag + { + guide_drop(ui, view, guides, item.node.id, r, &resp, out); + } +} + +/// Guide rows `guides` dragged over row `r` (`resp`) of layer `layer`: the row lights up, and the +/// release puts them on that layer (`guide.setLayer`, one undo step). +fn guide_drop(ui: &Ui, view: &View, guides: &[usize], layer: NodeId, r: egui::Rect, resp: &egui::Response, out: &mut Out) { + ui.painter().rect_stroke(r.shrink(1.0), 0.0, Stroke::new(2.0, view.t.accent), StrokeKind::Inside); + if resp.dnd_release_payload::().is_some() { + out.actions.push(("guide.setLayer".into(), json!({"indexes": guides, "layer": layer.0}))); + } +} + /// The widgets of row `item`. fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { + if let Some(i) = item.guide { + return guide_row(ui, view, item, i, out); + } let n = &*item.node; let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour); let doc = view.doc; @@ -777,6 +856,13 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: let moving: Vec = match &*drag { LayersDrag::Rows(ids) => ids.clone(), LayersDrag::Art => view.sel.iter().copied().collect(), + LayersDrag::Guides(guides) => { + // Guides go to the layer of the row (the row's own, for a layer). + if let Some(layer) = if n.is_layer() { Some(n.id) } else { doc.layer_containing(n.id) } { + guide_drop(ui, view, guides, layer, r, resp, out); + } + return; + } }; // Never into itself or a row inside it. let around = doc.ancestry(n.id).unwrap_or_default(); @@ -791,7 +877,7 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: LayersDrag::Art => Place::Above, LayersDrag::Rows(_) if inside && rel >= 0.25 && (rel <= 0.75 || open) => Place::Inside, LayersDrag::Rows(_) if rel < 0.5 => Place::Above, - LayersDrag::Rows(_) => Place::Below, + LayersDrag::Rows(_) | LayersDrag::Guides(_) => Place::Below, }; let stroke = Stroke::new(2.0, view.t.accent); match place { @@ -811,6 +897,7 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: // The selection's top objects, as Arrange and Group take them. LayersDrag::Art => doc.paint_order(moving.iter().copied()).iter().map(|i| i.0).collect(), LayersDrag::Rows(ids) => ids.iter().map(|i| i.0).collect(), + LayersDrag::Guides(_) => return, }; out.actions.push(("layer.move".into(), json!({"ids": ids, "target": n.id.0, "place": place.id(), "copy": copy}))); } @@ -1426,6 +1513,42 @@ mod tests { assert_eq!(d.layers.len(), 1); } + /// A layer's ruler guides are rows in it, above its art: a click selects the guide, a drag onto + /// another layer's row moves it there, and a guide on a locked layer is left alone. + #[test] + fn guide_rows_select_and_move_their_guides() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})); + run(&mut app, "guide.add", json!({"vertical": true, "pos": 50})); + run(&mut app, "select.none", json!({})); + let second = NodeId(run(&mut app, "layer.new", json!({}))["id"].as_u64().unwrap()); + expand(&mut app, &json!({})).unwrap(); + let ctx = egui::Context::default(); + // Rows top down: Layer 2, Layer 1, its guide (no target circle), the rectangle. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 3); + let guide_row = egui::pos2(150.0, (circles[1].y + circles[2].y) / 2.0); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone(); + click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); + assert_eq!(guides(&app), [0], "its row selects it"); + // Dragged onto Layer 2's row, it goes on Layer 2, in one undo step. + let undo = app.session.active().unwrap().history.undo.len(); + drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[0].y), false); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.guides[0].layer, st.history.undo.len()), (Some(second), undo + 1)); + // Now under Layer 2: locked, its row neither selects nor drags it. + run(&mut app, "layer.setProps", json!({"ids": [second.0], "locked": true})); + assert!(guides(&app).is_empty(), "deselected with its layer locked"); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let guide_row = egui::pos2(150.0, (circles[0].y + circles[1].y) / 2.0); + click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); + assert!(guides(&app).is_empty()); + drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[2].y), false); + assert_eq!(app.session.active().unwrap().doc.guides[0].layer, Some(second)); + } + /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows /// holding selected art). fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) { diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index ed9f2a627..023b3189f 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -42,9 +42,11 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) { /// right to left: its Paragraph Direction, else from its first strong character? fn resolved_rtl(app: &VectorcraftApp, para: &ParaStyle) -> bool { let editing = super::character::text_editing(app); - let selected = super::first_selected(app); - let node = editing.and_then(|(id, _, _)| app.session.active().and_then(|d| d.doc.node(id))).or(selected.as_ref()); - let Some(NodeKind::Text(t)) = node.map(|n| &n.kind) else { return para.direction == Some(ParaDirection::RightToLeft) }; + let text = editing.and_then(|(id, _, _)| match app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t.as_ref()), + _ => None, + }); + let Some(t) = text.or_else(|| super::character::first_selected_text(app)) else { return para.direction == Some(ParaDirection::RightToLeft) }; let plain = t.plain_text(); let paragraph = vectorcraft_text::edit::paragraph_at(&plain, editing.map_or(0, |(_, a, _)| a)); vectorcraft_text::paragraph_is_rtl(plain.get(paragraph).unwrap_or_default(), para.direction) @@ -246,6 +248,8 @@ mod tests { shown_alignment(para.justify, resolved_rtl(app, ¶)) }; assert_eq!(shown(&app), Justify::Right); + app.session.execute("object.group", &json!({})).unwrap(); + assert_eq!(shown(&app), Justify::Right, "group controls resolve the same text descendant's direction"); app.session.execute("text.setStyle", &json!({"justify": "left"})).unwrap(); assert_eq!(shown(&app), Justify::Left); app.session.execute("text.setStyle", &json!({"justify": "auto"})).unwrap(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index b48a4a748..ccd451ce8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -442,12 +442,26 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("object.rotate", json!({"angle": a, "absolute": true})).ok(); } ui.add_space(10.0); - if widgets::icon_button(ui, "flip-horizontal-2", tl!("Flip Along Horizontal Axis"), false, 24.0).clicked() { + // Lucide's flip icons are named for their mirror line: flip-vertical-2 draws a vertical + // line (the left-right flip), flip-horizontal-2 a horizontal one (top-bottom). + if widgets::icon_button(ui, "flip-vertical-2", tl!("Flip Along Horizontal Axis"), false, 24.0).clicked() { app.run("object.reflect", json!({"axis": "vertical"})).ok(); } - if widgets::icon_button(ui, "flip-vertical-2", tl!("Flip Along Vertical Axis"), false, 24.0).clicked() { + if widgets::icon_button(ui, "flip-horizontal-2", tl!("Flip Along Vertical Axis"), false, 24.0).clicked() { app.run("object.reflect", json!({"axis": "horizontal"})).ok(); } + // More Options: the Transform panel's Scale Corners and Scale Strokes & Effects, open + // while they are toggled. + let more = widgets::icon_button(ui, "ellipsis", tl!("More Options"), false, 24.0); + egui::Popup::menu(&more).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes); + if widgets::check(ui, tl!("Scale Corners"), sc, true) { + super::transform::set_pref(app, "scaleCorners", !sc); + } + if widgets::check(ui, tl!("Scale Strokes & Effects"), ss, true) { + super::transform::set_pref(app, "scaleStrokes", !ss); + } + }); }); // Live shape properties. if let Some(n) = first_selected(app) @@ -623,6 +637,44 @@ mod tests { assert!(widths.iter().all(|w| (w - widths[0]).abs() < 1.0), "X, rotation, corner radius, opacity: {widths:?}"); } + /// The Transform section's More Options holds Scale Corners and Scale Strokes & Effects, and + /// stays open while they are toggled. + #[test] + fn transform_more_options_toggles_scale_corners_and_strokes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); + let ctx = egui::Context::default(); + let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); + frame(&mut app, &ctx, vec![]); + let texts = frame(&mut app, &ctx, vec![]); + assert!(!has(&texts, "Scale Corners"), "closed at first"); + // The button is an icon at the end of the angle row, under the H: field: sweep that row. + let h = texts.iter().find(|(t, _)| t == "H:").unwrap().1; + let mut open = None; + 'sweep: for dy in (12..60).step_by(6) { + for x in (120..300).step_by(6) { + let at = egui::pos2(x as f32, h.bottom() + dy as f32); + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(at), b(true)]); + frame(&mut app, &ctx, vec![b(false)]); + let texts = frame(&mut app, &ctx, vec![]); + if has(&texts, "Scale Corners") { + open = Some(texts); + break 'sweep; + } + } + } + let texts = open.expect("More Options opens the popup"); + let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes); + click(&mut app, &ctx, &texts, "Scale Corners"); + assert_eq!(app.session.prefs.scale_corners, !sc); + let texts = frame(&mut app, &ctx, vec![]); + assert!(has(&texts, "Scale Strokes & Effects"), "still open after a toggle"); + click(&mut app, &ctx, &texts, "Scale Strokes & Effects"); + assert_eq!(app.session.prefs.scale_strokes, !ss); + } + /// Both panels forward the Move Artwork toggle when their actual X field is edited. #[test] fn artboard_coordinate_fields_move_art_in_properties_and_transform() { diff --git a/crates/ui-egui/src/panels/tests_fontsize.rs b/crates/ui-egui/src/panels/tests_fontsize.rs index e8f8d57ea..39baa95cc 100644 --- a/crates/ui-egui/src/panels/tests_fontsize.rs +++ b/crates/ui-egui/src/panels/tests_fontsize.rs @@ -61,6 +61,38 @@ fn a_picked_font_size_preset_applies_in_one_undo_step() { assert_eq!(undo_len(&app), undo + 1, "one undo step"); } +#[test] +fn scaled_font_size_fields_and_preset_use_document_points() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (mut app, id) = app_with_text(); + run(&mut app, "object.scale", json!({"sx": 200})); + assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt")); + assert!(shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "24 pt")); + let at = Cell::new(egui::Pos2::ZERO); + frame_events(&ctx, &mut app, vec![], size_combo(&at)); + click(&ctx, &mut app, at.get() + egui::vec2(110.0, 13.0), size_combo(&at)); + let t = frame_events(&ctx, &mut app, vec![], size_combo(&at)); + let undo = undo_len(&app); + click(&ctx, &mut app, text_rect(&t, "10 pt").center(), size_combo(&at)); + assert_eq!(style(&app, id).size * 2.0, 10.0); + assert_eq!(undo_len(&app), undo + 1); + assert!(shows(&frame_events(&ctx, &mut app, vec![], size_combo(&at)), "10 pt")); +} + +#[test] +fn scaled_group_shows_character_and_control_bar_size_fields() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (mut app, _) = app_with_text(); + run(&mut app, "object.group", json!({})); + run(&mut app, "object.scale", json!({"sx": 200})); + assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt")); + assert!(shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "24 pt")); + run(&mut app, "text.setStyle", json!({"size": 10})); + assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "10 pt")); +} + #[test] fn font_size_presets_show_in_the_type_unit() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index 9f6302636..b10b80da9 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -139,3 +139,37 @@ fn weight_presets_display_cleanly_in_their_native_unit() { // Units no stroke is measured in keep the pt ladder, in points. assert_eq!(stroke::weight_presets(Unit::Meters), stroke::weight_presets(Unit::Points)); } + +/// #991: the spinner left of the Stroke Weight field steps ten with Shift on both arrows and a +/// tenth with Ctrl/Cmd, as ↑/↓ do in the focused field, while a plain click steps one point. +#[test] +fn stroke_weight_stepper_uses_ten_point_shift_steps() { + use super::tests_appearance::frame_raw; + + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = app_with_rect(); + run(&mut app, "stroke.set", json!({"weight": 14})); + let t = texts(&ctx, &mut app, stroke::show); + // The 16 px spinner sits just left of the field's text. + let r = text_rect(&t, "14 pt"); + let x = r.left() - 14.0; + let click_step = |app: &mut VectorcraftApp, y: f32, modifiers: egui::Modifiers| { + let pos = egui::pos2(x, y); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + for event in [egui::Event::PointerMoved(pos), button(true), button(false)] { + let events = vec![egui::Event::ModifiersChanged(modifiers), event]; + frame_raw(&ctx, app, egui::RawInput { events, ..Default::default() }, stroke::show); + } + }; + + click_step(&mut app, r.center().y - 5.0, egui::Modifiers::SHIFT); + assert_eq!(app.session.shown_stroke().unwrap().width, 24.0); + click_step(&mut app, r.center().y + 5.0, egui::Modifiers::NONE); + assert_eq!(app.session.shown_stroke().unwrap().width, 23.0); + click_step(&mut app, r.center().y + 5.0, egui::Modifiers::SHIFT); + assert_eq!(app.session.shown_stroke().unwrap().width, 13.0); + // Ctrl/Cmd steps a tenth, as ↑/↓ and the wheel do in the field. + click_step(&mut app, r.center().y + 5.0, egui::Modifiers::COMMAND); + assert!((app.session.shown_stroke().unwrap().width - 12.9).abs() < 1e-6); +} diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index a531f2c8d..949da2acb 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -167,10 +167,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); }); ui.horizontal(|ui| { - if widgets::icon_button_enabled(ui, "flip-horizontal-2", tl!("Flip Horizontal"), false, has, 24.0).clicked() { + // Lucide's flip icons are named for their mirror line: flip-vertical-2 draws a vertical + // line (the left-right flip), flip-horizontal-2 a horizontal one (top-bottom). + if widgets::icon_button_enabled(ui, "flip-vertical-2", tl!("Flip Horizontal"), false, has, 24.0).clicked() { app.run("object.reflect", json!({"axis": "vertical", "origin": origin})).ok(); } - if widgets::icon_button_enabled(ui, "flip-vertical-2", tl!("Flip Vertical"), false, has, 24.0).clicked() { + if widgets::icon_button_enabled(ui, "flip-horizontal-2", tl!("Flip Vertical"), false, has, 24.0).clicked() { app.run("object.reflect", json!({"axis": "horizontal", "origin": origin})).ok(); } }); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index dc25ceb64..3908fa7a6 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -213,11 +213,15 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } if app.ui.dialog.is_some() || app.ui.palette_open { + if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) && crate::dialogs::revolve_gizmo::dragging(ctx) { + // Escape first restores this rotation gesture. The settings stay open. + return; + } if crate::shortcut_editor::is_recording(app) { return; } if ctx.input(|i| i.key_pressed(Key::Escape)) { - app.ui.dialog = None; + crate::dialogs::cancel(app); app.ui.palette_open = false; } return; @@ -620,7 +624,8 @@ mod tests { out.textures_delta.clear(); }; let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); - let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + // The undo steps and the journal entries (the dialog's OK runs its command, which the journal records). + let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len()); app.select_tool("selection"); frame(&mut app, vec![enter()]); assert_eq!(kind(&app), None, "nothing is selected"); @@ -641,9 +646,11 @@ mod tests { frame(&mut app, vec![]); assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: and it stays open"); assert_eq!(steps(&app), before, "{tool}: the Enter that opened it didn't confirm it"); - // The next Enter is its OK. + // The next Enter is its OK. With its default values only Reflect changes the art, so only + // Reflect records an undo step. frame(&mut app, vec![enter()]); - assert_eq!((kind(&app), steps(&app)), (None, before + 1), "{tool}: Enter confirms"); + let after = (before.0 + usize::from(dialog == "reflect"), before.1 + 1); + assert_eq!((kind(&app), steps(&app)), (None, after), "{tool}: Enter confirms"); } app.select_tool("rectangle"); frame(&mut app, vec![enter()]); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 249bcb2fc..cd0fb4cf3 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -445,7 +445,12 @@ pub struct TaskBarPlace { } /// A group of panels dragged out of the dock: it floats as a stack of tabs, one panel shown. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// +/// Groups sharing a `column` float as one set, stacked top to bottom in list order, like the panel +/// groups of a dock in Illustrator's workspace +/// (): the set moves by the top +/// group's title bar, and a group dragged by its tab strip leaves it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct FloatingPanels { /// Panel ids (`window.panel`), in tab order. pub panels: Vec, @@ -457,6 +462,17 @@ pub struct FloatingPanels { /// A width the group was given (the Tabs panel sized over its text), else its panels' own. #[serde(default, skip_serializing_if = "Option::is_none")] pub width: Option, + /// The set this group is stacked in with others (the same number), top to bottom in list + /// order; none when it floats on its own. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub column: Option, + /// Collapsed to its tabs (a double-click on a tab), its panel hidden. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub collapsed: bool, + /// Docked as a column of the dock (with the rest of its set) instead of floating: the columns + /// stand left of the icon column, 1 next to it, counting left. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docked: Option, } impl FloatingPanels { @@ -469,10 +485,45 @@ impl FloatingPanels { g.active = g.active.min(g.panels.len().saturating_sub(1)); !g.panels.is_empty() }); + Self::tidy(groups); if toolbar_pos.is_some_and(|p| !p.iter().all(|v| v.is_finite())) { *toolbar_pos = None; } } + + /// Keep each set's groups together in the list, after its first one and in the order they + /// come, at its first one's position and in its dock column; a set left with one group floats + /// (or stays docked) on its own; the dock columns are numbered 1, 2… without gaps. + pub fn tidy(groups: &mut Vec) { + let mut out: Vec = Vec::with_capacity(groups.len()); + for mut g in groups.drain(..) { + match g.column.and_then(|c| out.iter().rposition(|o| o.column == Some(c))) { + Some(i) => { + if let Some(first) = out.iter().find(|o| o.column == g.column) { + g.pos = first.pos; + g.docked = first.docked; + } + out.insert(i + 1, g); + } + None => out.push(g), + } + } + let alone: Vec = out.iter().filter_map(|g| g.column).filter(|c| out.iter().filter(|g| g.column == Some(*c)).count() < 2).collect(); + for g in &mut out { + if g.column.is_some_and(|c| alone.contains(&c)) { + g.column = None; + } + } + let mut numbers: Vec = out.iter().filter_map(|g| g.docked).collect(); + numbers.sort_unstable(); + numbers.dedup(); + for g in &mut out { + if let Some(d) = g.docked { + g.docked = numbers.iter().position(|n| *n == d).and_then(|k| u32::try_from(k + 1).ok()); + } + } + *groups = out; + } } impl UiState { diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index a5b37af9a..eeade4fb9 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -95,6 +95,24 @@ fn effect_menu_lists_the_distort_filters_between_blur_and_pixelate() { assert!(raster.contains(&"Distort & Transform")); } +/// Effect › Brush Strokes, between Blur and Distort: each filter opens its dialog. +#[test] +fn effect_menu_lists_the_brush_strokes_filters_between_blur_and_distort() { + let app = app(); + let entries = menus::menu_entries(&app); + let strokes: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Brush Strokes"]).collect(); + let labels: Vec<&str> = strokes.iter().map(|e| e.label.as_str()).collect(); + assert_eq!( + labels, + ["Accented Edges…", "Angled Strokes…", "Crosshatch…", "Dark Strokes…", "Ink Outlines…", "Spatter…", "Sprayed Strokes…", "Sumi-e…"] + ); + assert!(strokes.iter().all(|e| e.command.as_deref() == Some("effect.dialog"))); + let raster: Vec<&str> = + entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect(); + let at = |name: &str| raster.iter().position(|s| *s == name).unwrap(); + assert!(at("Blur") < at("Brush Strokes") && at("Brush Strokes") < at("Distort")); +} + /// Effect › Texture, between Sharpen and Video: each filter opens its dialog. #[test] fn effect_menu_lists_the_texture_filters_between_sharpen_and_video() { diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index 5577bd763..d08044e4e 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -546,3 +546,68 @@ fn crop_image_shows_a_box_that_apply_crops_to() { app.run("select.none", json!({})).unwrap(); assert!(!crate::menus::enabled(&app, "ui.cropImage") && app.run("ui.cropImage", json!({})).is_err()); } + +/// One headless frame of the whole window at `time` → how long the app asked to wait before the +/// next one. +fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64) -> std::time::Duration { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map_or(std::time::Duration::MAX, |v| v.repaint_delay) +} + +/// The window sits idle for `secs` from `*time` (a frame now, then only the frames the app asks +/// for) or until `done` → whether it was. +fn idle(app: &mut VectorcraftApp, ctx: &egui::Context, time: &mut f64, secs: f64, done: &dyn Fn(&VectorcraftApp) -> bool) -> bool { + let end = *time + secs; + loop { + let wait = frame_at(app, ctx, *time); + if done(app) { + return true; + } + let next = *time + wait.as_secs_f64().max(1.0 / 60.0); + if next > end { + *time = end; + return false; + } + if app.session.link_scan.is_some() { + // The look runs on a worker thread, in real time. + std::thread::sleep(std::time::Duration::from_millis(5)); + } + *time = next; + } +} + +/// A file placed into a document whose last look found no links, then changed by another app +/// while the window sits idle in the background, follows Update Links: Automatically: the app +/// wakes itself to see the new link before the change, so the change isn't taken for the file as +/// first seen. +#[test] +fn a_file_placed_while_idle_follows_its_changes() { + let mut app = app(); + app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap(); + let ctx = egui::Context::default(); + let mut time = 0.0; + idle(&mut app, &ctx, &mut time, 3.0, &|_| false); + // A look finds the new document without links: no wakes of its own for it. + assert_eq!(frame_at(&mut app, &ctx, time), std::time::Duration::MAX, "{:?}", ctx.repaint_causes()); + // Half a second later, before the next look is due, a file is placed. The place itself is a + // frame (as any command); then nothing happens for 3 s. + time += 0.5; + let path = temp_file("idle-watch.png", &png(40, 30, 72.0)); + app.run("file.place", json!({"path": path})).unwrap(); + let id = app.session.active().unwrap().selection.objects[0]; + idle(&mut app, &ctx, &mut time, 3.0, &|_| false); + std::thread::sleep(std::time::Duration::from_millis(30)); + let changed = png(60, 30, 72.0); + std::fs::write(&path, &changed).unwrap(); + let hash = vectorcraft_doc::links::hash_bytes(&changed); + let updated = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + matches!(&st.doc.node(id).unwrap().kind, NodeKind::Image(im) if im.link.as_ref().and_then(|l| l.hash.as_deref()) == Some(hash.as_str())) + }; + assert!(idle(&mut app, &ctx, &mut time, 10.0, &updated), "the changed file was never read again"); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 4618d6544..3c652dfdd 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -89,6 +89,10 @@ pub struct Tokens { pub error: Color32, /// Warning marks (a modified linked file). pub warning: Color32, + /// Rotation gizmo axes, also labelled X, Y and Z. + pub axis_x: Color32, + pub axis_y: Color32, + pub axis_z: Color32, } impl Tokens { @@ -133,6 +137,9 @@ impl Tokens { bleed: hex(0xf03030), error: hex(0xe34850), warning: hex(0xf0a330), + axis_x: hex(0xef6058), + axis_y: hex(0x45af63), + axis_z: hex(0x378ef0), }; match b { Brightness::Dark => base, diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 6cb9da82a..d8883a179 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1170,8 +1170,8 @@ fn spin_generic( && let Some(p) = sresp.interact_pointer_pos() && let Some(v) = value { - let nv = if up.contains(p) { v + step } else { v - step }; - out = Some(SpinPick::Value(nv.max(min))); + let modifiers = ui.input(|i| i.modifiers); + out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers))); } let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; if let Some(v) = field(ui, fw) { @@ -1198,6 +1198,14 @@ fn spin_generic( out } +/// Spinner buttons follow the same modifier sizes as ↑/↓ in a focused number field: +/// Shift ×10, Ctrl/Cmd ÷10, otherwise the caller's normal step. +fn spinner_step(value: f64, step: f64, min: f64, up: bool, modifiers: egui::Modifiers) -> f64 { + let multiplier = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)).map_or(1.0, |(_, factor)| *factor); + let delta = step * multiplier; + (value + if up { delta } else { -delta }).max(min) +} + /// Drag phase of a live slider/handle: previews while dragging, commits on release. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Live { @@ -1826,3 +1834,21 @@ pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, val }); out } + +#[cfg(test)] +mod spinner_modifier_tests { + use super::*; + + #[test] + fn shift_steps_stroke_weight_by_ten_points() { + assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::SHIFT), 24.0); + assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::SHIFT), 4.0); + } + + #[test] + fn unmodified_and_command_steps_keep_existing_behavior() { + assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::NONE), 15.0); + assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::COMMAND), 13.9); + assert_eq!(spinner_step(0.0, 1.0, 0.0, false, egui::Modifiers::SHIFT), 0.0); + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..5bca67dcb --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,73 @@ +# Architecture + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, written from the workspace as it is on origin/main `01e165af`) · **Target:** Adobe Illustrator 2026 (30.x) + +How VectorCraft is built today. The design rationale lives in the local, gitignored `plan/architecture.md`; +this file describes what is committed. Day-to-day rules (clean-room, never crash, quality gates) are in +[`AGENTS.md`](../AGENTS.md) and [development.md](development.md). + +## Shape + +A Cargo workspace of 23 library crates under `crates/`, three apps under `apps/` and `xtask`, ~354k lines of Rust, +pure Rust (no handwritten JS/TS). The same engine runs the desktop app, the browser build (WASM), the headless +CLI and the MCP server. + +## Layers + +`cargo xtask layers` (part of `cargo xtask ci`) enforces that a crate depends only on lower layers, and that +nothing below L6 depends on a UI toolkit (egui, eframe, winit, rfd). Table: `xtask/src/layers.rs`. + +| Layer | Crates | Role | +|---|---|---| +| L0 | `geom`, `color`; `affinity` (standalone, no workspace deps) | curves and transforms (on `kurbo`), colour models, ICC and conversions; the Affinity reader | +| L1 | `doc` | the document model: artboards, layers, objects, appearance, text, symbols; pure data + serde, structural sharing | +| L2 | `pathops`, `brush`, `trace`, `text`, `effects`, `plugins`, `three-d` | booleans/Shape Builder/offset/outline; brushes; Image Trace; fonts, shaping (HarfRust, skrifa), bidi and layout; live effects and the pixel-filter pipeline; sandboxed WebAssembly plug-ins (wasmi); 3D surfaces of revolution, projection and shading (`three-d`, on `geom` only) | +| L3 | `render`, `svg`, `pdf`, `format`, `eps`, `cad`, `metafile` | document to pixels (`vello_cpu`, multithreaded); SVG, PDF, native JSON, EPS/PostScript (with its own interpreter), DXF, EMF/WMF readers and writers | +| L4 | `tools` | tool state machines: pointer events in, commands and overlays out (Pen, Direct Selection, smart guides…) | +| L5 | `engine` | sessions, history (unlimited undo by structural sharing), selection, the command registry (~710 commands), file I/O, the panic guard | +| L6 | `ui-egui`, `mcp` | the egui front end (menus, panels, dialogs, canvas, i18n); the MCP server (JSON-RPC over stdio) | +| L7 | `apps/vectorcraft`, `apps/vectorcraft-cli`, `apps/vectorcraft-web`, `xtask` | desktop app (eframe, wgpu), CLI (`run`, `convert`, `info`, `bench`, `perf`, `mcp`), browser runner (WebGPU, WebGL2 fallback), build tooling | + +`testkit` holds test helpers (fixtures, generators, render comparison) and is only a dev-dependency. + +## Everything is a command + +Every user-visible action is a `CommandSpec` in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, +params doc, `enabled`, `run`, journal flag); UI-only commands are `UI_COMMANDS` in +`crates/ui-egui/src/menus.rs`. Tools emit commands (begin, preview, commit). Menus, the command palette, Actions, +the control channel, the CLI and MCP all reach the same registry, so anything a user can do an agent can do. +`command.batch` runs several as one undo step. + +## Rendering + +The canvas is rasterized on the CPU by `vello_cpu` on worker threads, off the UI thread, with caches per object +and per effect; the GPU (wgpu: Metal, DX12, Vulkan/GL; WebGPU/WebGL2 on the web) only composites the result. Raster +effects are rendered offscreen per object and cached while panning. See +[hardware-parity.md](hardware-parity.md). + +## Files + +The native `.vectorcraft` format is versioned, compressed JSON with embedded images, saved atomically, readable +down to v1 and from the old `.drawcraft` name. The `FORMATS` table in `crates/engine/src/cmd/fileio/mod.rs` +lists every format read or written; importers are fuzzed (`crates/engine/tests/import_fuzz.rs`). See +[file-format-parity.md](file-format-parity.md). + +## Agent control + +- **Control channel:** JSON lines on a local TCP port (`--control 7979`), with real egui pointer and keyboard + injection and screenshots ([control-protocol.md](control-protocol.md)). +- **MCP:** `vectorcraft-cli mcp`, attached to a running app or headless ([mcp.md](mcp.md)). +- **Sandboxing:** `--automation-read-root` / `--automation-write-root` confine what agents read and write. + +## Robustness + +No panics in shipped code (workspace clippy lints ban `unwrap`, `expect`, `panic!` and friends outside tests); +`vectorcraft_engine::guard` catches a panic at every entry point and rolls the document back; Data Recovery +saves copies in the background. See [development.md](development.md#robustness-vectorcraft-never-crashes). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Added the `three-d` crate | +| 2026-10-10 | major | First version, from the workspace, the layering table and the existing docs | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 43fb7061b..98dfcb125 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -61,7 +61,7 @@ the two can still win that race. | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | | | `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | -| `ui.render` | `{path?, scale?, data?}` | render the artboard headlessly (PNG): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | +| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | @@ -261,17 +261,38 @@ Constrain acts as Shift held (proportional scaling, moves and rotations by 45°) where it is greyed. The modifier keys still work while dragging (Cmd on a corner distorts it freely, Cmd+Alt+Shift in perspective, Cmd on a side shears). +Ruler guides are layer objects: `guide.add` puts a guide on the current layer (or `layer`), where it shows, hides, +locks and is deleted with its layer and is listed in the Layers panel (a click on its row selects it, a drag onto +another layer's rows moves it there); `guide.setLayer {index?, indexes?, layer}` moves guides to another layer, and +`guide.list` gives each guide's `layer`, `shown` and `editable`. Guides on a hidden or locked layer leave the selection +and can't be picked, moved or deleted, and no guide goes onto a locked layer; Duplicate copies a layer's guides, Merge +Selected and Flatten Artwork keep them on the merged layer (Flatten discards a hidden layer's). Guides in files saved +before guides had layers go on the top visible, unlocked layer when the document opens. + Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own; dropped on another group's title bar or tabs it stacks with that group, and dropped on the dock (lit up while the -pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column. -`window.panel.float {panel, x?, y?, onto?, group?}` floats a panel (with `group`, its whole group) with its top-left -corner at `x`, `y` (window points; default cascaded), or stacks it with the floating group holding `onto`; -`window.panel.dock {panel, group?}` puts it back. `panel` takes the `window.panel` ids and labels, and `"tools"` for +pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column. Dropped on the upper or +lower half of another group, floating or docked (a line shows where), a group joins it in a set right above or below +it: the groups stack top to bottom, each +showing one of its panels, and move together by the top group's title bar, whose × docks them all; a group dragged by +its tab strip (a lone panel's tab or the strip right of the tabs) leaves the set. A double-click on a tab collapses a +group to its tabs, and another expands it. Dropped on the left edge of the icon column or of a dock column, a set +docks as a new dock column there: its groups stack in it as in a floating set, take groups dropped on their edges and +tabs, and leave it dragged by their tab strip; the strip along the column's top floats the whole column again, and +its × puts its panels back where they live. +`window.panel.float {panel, x?, y?, onto?, below?, above?, group?, collapsed?}` floats a panel (with `group`, its whole +group) with its top-left corner at `x`, `y` (window points; default cascaded), or stacks it as a tab with the floating +group holding `onto`, or puts its group in a set right `below` or `above` the group holding that panel; `collapsed` +collapses or expands its group; +`window.panel.dock {panel, group?, column?}` puts it back, or with `column` (`true`, or the place: 1 next to the icon +column, counting left) docks its group and set as a dock column; `window.panel.float` on a docked panel floats its +column. `panel` takes the `window.panel` ids and labels, and `"tools"` for the Tools panel, which floats by its title bar and docks on the window's left edge. `window.panel` on a floating -panel shows its tab. `ui.floating_panels` in `ui.inspect` lists the groups (panel ids, the index of the tab shown, -the top-left corner) and `ui.toolbar_pos` the Tools panel's corner (null: docked); both are saved with the +panel shows its tab (expanding its group). `ui.floating_panels` in `ui.inspect` lists the groups (panel ids, the +index of the tab shown, the top-left corner, `column`: the set it is stacked in, top to bottom in list order, +`collapsed`, and `docked`: the dock column it stands in) and `ui.toolbar_pos` the Tools panel's corner (null: docked); both are saved with the preferences and in user workspaces (the built-in workspaces dock everything), and a corner saved on a bigger window is clamped into this one when drawn. Floating panels stay inside the app window (no separate OS windows). diff --git a/docs/development.md b/docs/development.md index 651284e5b..9fd227210 100644 --- a/docs/development.md +++ b/docs/development.md @@ -57,6 +57,8 @@ The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU ( - **Automatic on Linux and the BSDs** is the GPU that drives the panel or a monitor, then the system's order: Mesa's Vulkan device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid laptops; `DRI_PRIME` and `MESA_VK_DEVICE_SELECT` steer it). A Wayland compositor may not accept frames from another GPU: on a desktop whose compositor ran on an NVIDIA GPU, rendering on the Ryzen's integrated GPU made KWin end the window's connection ("importing the supplied dmabufs failed") and 0.5.0 crashed at startup (#502). - 0.5.0 saved `powerSaving`, its default, for everyone; it reads as `automatic`. - **If the window fails on its adapter while starting up** (an error from wgpu, or a panic inside wgpu or egui-wgpu during the first frames), the app starts again without that adapter and tries the next one, telling which in the status bar; the run that failed keeps its log as `vectorcraft.1.log`. On Unix the new app replaces the process (an AppImage stays mounted); on Windows it starts beside it. Adapters are told apart by backend and PCI ids, or by name where the backend reports no ids (Metal lists every GPU of a dual-GPU Mac as `0000:0000`, #651). The adapters left out travel in `VECTORCRAFT_GPU_SKIP` (`backend:vendor:device`, such as `Vulkan:1002:164e`), one more on each restart, so the restarts end. When no adapter is left, the app logs why and exits with an error instead of panicking. +- **If the window's first frame never reaches the screen** within 20 seconds of the app being created (the files named at the start opened), the adapter counts as hanging and the app starts again without it in the same way (#964: High Performance on a hybrid laptop left the app running in the background with no window and nothing in the log). A window minimized at the start isn't counted. When the processor that hung was chosen in Preferences, the status bar points to Automatic. +- **To recover from a Graphics Processor choice that doesn't work** without editing files: start once with `WGPU_POWER_PREF=low` (or `WGPU_ADAPTER_NAME=…`), which overrides the preference for that start while settings still save, and set Graphics Processor back to Automatic. `VECTORCRAFT_NO_PREFS=1` also starts without it, but saves nothing. - **Environment variables** for when the choice still goes wrong, all read at startup and stronger than the preference: - `WGPU_POWER_PREF`: `low`, `high` or `none` (the system's order). - `WGPU_ADAPTER_NAME`: part of an adapter's name, any case (`WGPU_ADAPTER_NAME=nvidia`, `=radv`, `=intel`); the names are in the log. diff --git a/docs/effects-parity.md b/docs/effects-parity.md new file mode 100644 index 000000000..afd2338ce --- /dev/null +++ b/docs/effects-parity.md @@ -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) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 000000000..2adb0684b --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,86 @@ +# File format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Lab spot colours in `.ai` and PDF, #1032) · **Target:** Adobe Illustrator 2026 (30.x) + +Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, +`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public +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. A `.ai` in PostScript form (versions 3 to 8, and what Rhino and other CAD apps + write) is its own editing data: it opens through the layers its program has, compared with its page + when the program runs, from the layers alone when it names its prolog without including it (#1027). +- **Writes** a PDF-compatible `.ai` (Save As › `ai`): Illustrator opens it as plain art. It never writes + Illustrator's private editing data, which is undocumented. +- **Known failures in users' files:** layer structure flattened or multiplied (#951: 109 layers instead of + 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction + lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects, + brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai` + without the prolog opens since #1027, its legacy type (`To` … `TO`) not read yet. +- Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible only). + Closing the open-side issues: 10–20 h with users' files. + +### 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, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. + +## Format by format + +| 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-11 | minor | Spot colours defined in Lab keep their Lab values, from the editing data and from a PDF part whose ink alternate is an ICC Lab profile (#1032) | +| 2026-10-11 | minor | `.ai` in PostScript form opens through its layers, also without its prolog (#1027) | +| 2026-10-11 | minor | `.ai` editing data: global process colours and pattern fills read, so they no longer drop sublayers (#1025) | +| 2026-10-10 | major | First checklist; moved the `.ai` editing-data scope from the ROADMAP's "Out of scope" here; added the user-reported `.ai` failures | diff --git a/docs/gaps.md b/docs/gaps.md new file mode 100644 index 000000000..2b03706c4 --- /dev/null +++ b/docs/gaps.md @@ -0,0 +1,212 @@ +# Gaps + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x) + +Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from +switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make +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 type in `.ai` files in PostScript form (those without + their prolog open since #1027), live effects, brushes and symbols as live objects (#637; pattern fills and global colours are read since #1025). Writing Illustrator's own editing data is out of scope + (undocumented); Save As `.ai` stays PDF-compatible. +- **Evidence:** the issues above; 28 of the 350 issues are about `.ai`/EPS (about 22 people, the most common single + format theme), 20 fixed, and the 8 open ones are about structure (layers, guides, speed, live objects), not missing + 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 + +- **Done:** scaled text's size and horizontal-scale controls account for object/group transforms (#1034), + including exact sizes across grouped labels and resetting horizontal stretch while keeping editable text. +- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Transform effect dialog's invisible checkboxes and missing options (#885); + PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834). +- **Impact:** wrong output in everyday work. +- **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; optional preservation of font sizes during group scaling (#1034 enhancement). + Itemized in [type-parity.md](type-parity.md). +- **Impact:** professional typography and Japanese/Chinese publishing. +- **Estimate:** 34–47 h (type core 14–19 h blocks beta). + +## 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-11 | minor | #1034: corrected scaled type controls; optional group font-size preservation remains separate. Estimates unchanged at this rounding. | +| 2026-10-11 | minor | G9: legacy `.ai` without the prolog opens with its layers (#1027) | +| 2026-10-11 | minor | G9: `.ai` pattern fills and global colours read from the editing data (#1025) | +| 2026-10-10 | minor | G3: initial Revolve landed (#846), 45–75 h left | +| 2026-10-10 | minor | G9 evidence counted from the tracker (28 `.ai`/EPS issues, 20 fixed); impact narrowed to handing files back | +| 2026-10-10 | minor | Alpha blockers checked against the core-workflow gate: none | +| 2026-10-10 | major | Moved from the ROADMAP's "Where we're lacking"; kept ids G1–G8, added G9–G19 (file formats, stability, bugs, preferences, 30.x additions, pen hardware, performance, localization, ecosystem, AI, accessibility) and re-ranked | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 000000000..cc8039e85 --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,36 @@ +# Hardware parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist) · **Target:** Adobe Illustrator 2026 (30.x) + +What hardware Illustrator uses, per platform, against VectorCraft. Illustrator's side is from its public +documentation (GPU Performance, touch workspace, pen pressure pages); ours from the code +(`crates/ui-egui/src/canvas.rs`, `touch.rs`, `graphics.rs`, `crates/render`). Part of +[target-app-parity.md](target-app-parity.md). + +## Summary + +**~35% (estimated), 25–45 h.** VectorCraft rasterizes the canvas on the CPU (multithreaded `vello_cpu`) and uses +the GPU only to composite, so any GPU that can show a window works, at the cost of GPU-accelerated preview +on huge documents. Pen input is pressure-only and Windows-first. + +| Feature | Illustrator | VectorCraft macOS | Windows | Linux | Web | Status / hours | +|---|---|---|---|---|---|---| +| GPU-accelerated canvas (GPU Performance, GPU Preview, GPU anti-aliasing) | Metal, DirectX | CPU raster, GPU composite (wgpu/Metal) | CPU raster, GPU composite (DX12, OpenGL fallback) | CPU raster, GPU composite (Vulkan/GL) | WebGPU, WebGL2 fallback | partial; a GPU renderer spike is pending (M5): 15–25 h | +| Multithreaded rendering off the UI thread | partial | yes | yes | yes | single-threaded | beyond Illustrator | +| Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done | +| Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done | +| Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | **no** (winit reports none, #852) | yes (pointer force via `WM_POINTER`) | X11 only via XWayland; none on Wayland (#491, #764) | browser pointer events | partial: 4–8 h for macOS and Wayland (winit 0.31) | +| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | no | no | no | no | missing: 4–8 h after the input layer (#372) | +| Pen eraser end | yes | no | no | no | no | missing | +| Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch and scroll | pinch, pan; two-finger tap undo, three-finger redo (#585) | same where winit reports touch | same | partial: touch workspace and rotate gesture missing, 3–5 h | +| Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | +| HiDPI / Retina, per-monitor scale | yes | yes | yes | yes | yes | done | +| Multiple monitors: panels and document windows on another screen | yes | no (one OS window) | no | no | — | missing: 6–10 h (with multiple windows, gap G7) | +| Display colour profile (proof and display through the monitor's ICC) | yes | no | no | no | — | missing: 3–5 h | +| 3D GPU rendering (Materials, ray tracing) | yes | — | — | — | — | blocked on 3D (gap G3) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First checklist from the canvas, touch and graphics code and the open hardware issues | diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 000000000..c7123a1e4 --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,66 @@ +# Localization parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language table, measured from the catalogs) · **Target:** Adobe Illustrator 2026 (30.x) + +Per-language status of VectorCraft's interface. How the catalogs work and how to add a language: +[`development.md` › Localisation](development.md#localisation). Part of +[target-app-parity.md](target-app-parity.md). + +**How it is measured:** rows with a translation in each `crates/ui-egui/src/i18n/.tsv`, counted on +origin/main `01e165af`. The reference is the French catalog, which the tests hold complete: 3,416 interface +strings (menus, command labels, `tl!` literals: panels, dialogs, tooltips, Preferences) plus 604 `@msg` status and +error messages, 4,020 in all. A language's % is its rows over 4,020 (capped at 100% per part). The +`complete_menus` flag and `COMPLETE_MESSAGES` list make `i18n::tests` fail if a complete language misses a +string, so "full" here is enforced by tests. No language has help content: there is no translated (or English) +in-app help, and the docs are English. + +**Native review:** every catalog was written clean-room from the English meaning, most of it by agents +(`bflatastic`), with community contributors adding or correcting (German by `dwetscher`, Japanese and Traditional +Chinese by `imachus`). None is recorded as reviewed by a native speaker; the catalog headers ask for one. + +## The twelve key languages + +| Language | Code | UI strings translated | Dialogs / tooltips / messages | Script support | Native review | Status | To `full` (h) | +|---|---|---|---|---|---|---|---| +| English | `en` | source, 100% | all | Latin | — | full | — | +| Simplified Chinese (Mandarin) | `zh-hans` | 3,647 rows; menus, panels, dialogs complete (tests); 85% overall | messages in English (1 of 604) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | partial | 2–3 | +| Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | +| Hindi | `hi` | 0 | none | Devanagari: the document's text shapes (HarfRust), untested for Devanagari, but the egui interface does no complex shaping | no | none | 20–30 | +| Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | +| French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) | +| Portuguese | `pt-br` | 2,726 / 4,020 (68%): every menu label and `tl!` literal | messages in English | Latin | no | partial | 4–6 | +| Indonesian | `id` | 0 | none | Latin | no | none | 5–8 | +| Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | +| German | `de` | 3,952 / 4,020 (98%; tests hold menus, literals and messages complete) | all (602 of 604 message rows; landed 2026-10-10) | Latin | contributor-written, not reviewed | full | — (review) | +| Korean | `ko` | 0 | none | Hangul needs a UI font in craft-fonts; the IME path exists | no | none | 6–10 | +| Vietnamese | `vi` | 0 | none | Latin with stacked diacritics (precomposed forms render) | no | none | 5–8 | + +**12-language score: ~54%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 68 + 0 + 100 + 98 + 0 + 0, over 12). +**To `full` for all twelve: ~70–110 h**, most of it Arabic (right-to-left interface) and Hindi (interface +shaping). Each new Latin-script catalog is ~4,000 rows; the existing ones were written in a few agent hours each +plus review. Native-speaker review is human work and isn't in the hours. + +## Other shipped languages + +| Language | Code | Translated | Status | +|---|---|---|---| +| Traditional Chinese (Taiwan) | `zh-hant` | 3,425 rows, menus and literals complete; messages in English (80% overall) | partial | +| Italian | `it` | 4,011 / 4,020 | full | +| Russian | `ru` | 4,009 / 4,020 | full | +| Ukrainian | `uk` | 4,009 / 4,020 (one/few/many plurals) | full | +| Czech | `cs` | 679 / 4,020 (17%): every menu label | menus only | + +Eleven non-English catalogs in all. Illustrator, as its public documentation lists, ships around two dozen +interface languages (including Korean, Polish, Dutch, Turkish, the Nordic languages, Hungarian, and Middle East +editions with Arabic and Hebrew); it doesn't ship Hindi, Indonesian or Vietnamese. + +## Not done in any language + +Locale-aware number and date formats, a right-to-left interface, translated help, and a Traditional Chinese UI +font for the web build. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First per-language table, measured from the catalog row counts against the complete French catalog | diff --git a/docs/mcp.md b/docs/mcp.md index e5582c8f3..92ac77bed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -284,7 +284,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | -| `undo` / `redo` | `{}` | | +| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. | With `mods.shift`, a marquee dragged with the Selection tool toggles the objects it reaches (the selected ones leave the selection, the others join it, the rest stays), and one dragged with Direct or Group Selection toggles the anchors @@ -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`, @@ -2599,6 +2627,19 @@ without ideographs uses its em box. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"charAlign":"icfTop"}}} ``` +## Proportional Metrics + +`text.setFormat {proportionalMetrics: true}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, +proportionalMetrics}` (a range) set full-width glyphs on the proportional widths the font gives them with the +OpenType `palt` feature; glyphs the font doesn't re-space keep their full width. Line-end Punctuation Half Width then takes nothing more off the punctuation `palt` re-spaced (the opening +bracket at a line's start, consecutive punctuation, the closing mark at a line's end); punctuation left full width is +trimmed as before. Off by default and saved only when on. Horizontal type only for now: vertical type sets as before. +One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"proportionalMetrics":true}}} +``` + ## New type in a Japanese interface While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em diff --git a/docs/releasing.md b/docs/releasing.md index fe475dd26..bfa8782c8 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,7 +1,7 @@ # Releasing VectorCraft Every push to the `release` branch runs `.github/workflows/release.yml`. The workflow builds -installers for macOS, Windows, Linux (including Flatpak bundles) and FreeBSD, plus the web build, and creates or updates a +installers for macOS, Windows, Linux (including Flatpak bundles and a RISC-V tarball) and FreeBSD, plus the web build, and creates or updates a **draft** GitHub Release named `VectorCraft v`. Nobody sees a draft until a maintainer publishes it. @@ -50,6 +50,7 @@ and no draft release is created. | Windows on ARM64 | `vectorcraft--windows-arm64.msi`, `vectorcraft--windows-arm64-portable.zip` | `windows-latest` (cross-compiled) | | Linux x86_64 | `vectorcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `vectorcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| Linux riscv64 | `vectorcraft--linux-riscv64.tar.gz` (cross-compiled, glibc >= 2.39; CLI smoke-tested under QEMU) | `ubuntu-24.04` | | Linux AppImage updates | `vectorcraft--linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImages | | Flatpak x86_64, aarch64 | `vectorcraft--linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) | | FreeBSD 14 x86_64 | `vectorcraft--freebsd-x86_64.tar.gz` | a FreeBSD 14.3 VM on `ubuntu-latest` | @@ -144,6 +145,11 @@ glibc 2.35 or newer: Ubuntu 22.04+, Debian 12+, Fedora 36+ and RHEL 10. Moving t image raises that floor, so do it deliberately. After packaging, the job prints the `.deb`'s metadata and contents, runs `ldd` on the binary and runs each AppImage with `--version`. +**RISC-V.** The `linux-riscv64` job cross-compiles `riscv64gc-unknown-linux-gnu` on `ubuntu-24.04` +against riscv64 libraries from ports.ubuntu.com (so the floor there is glibc 2.39) and runs +`package.sh --formats tar` with `CROSS_ARCH`, `CROSS_TARGET`, `CROSS_COMPILE` and +`EMULATOR=qemu-riscv64` set: a `.tar.gz` only, with `vectorcraft-cli --version` run under QEMU. + **AppImage updates.** Each AppImage embeds the update information `gh-releases-zsync|storytold|vectorcraft|latest|vectorcraft-*-linux-.AppImage.zsync` (appimagetool `-u`), and `package.sh` writes the matching `.zsync` beside it when `zsyncmake` (the diff --git a/docs/revolve.md b/docs/revolve.md new file mode 100644 index 000000000..9591ef739 --- /dev/null +++ b/docs/revolve.md @@ -0,0 +1,79 @@ +# Revolve + +This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567). + +Draw an open profile with the Pen tool, select it, then choose **Effect > 3D and Materials > Revolve**. +The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right +edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical +line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface. + +**Revolve angle** sweeps 0 to 360 degrees. **Rotation X/Y/Z** rotates the viewing direction, with all three axes defaulting to 0 degrees. **Perspective** +is zero for an orthographic view. **Segments** controls radial resolution (8 to 128 for a full turn). +Lighting is a directional light with azimuth, elevation, intensity and ambient contribution. +Turn **Shading** off to keep the source paint without lighting. + +The dialog has **Rotation** and **Lighting** tabs. Rotation groups the profile and view controls. Drag a value or its label +to adjust it, use its slider, or double-click the value to type. Angles show degrees and lighting +values show percentages. **Segments** is in the **Advanced** section. Lighting controls are disabled +when **Shading** is off. A filled dot at the end of a row means the value differs from its default; +click the dot to reset that value. **Reset all** restores every Revolve parameter to its default. + +The compact settings dialog opens beside the artboard. With **Preview** enabled, the selected +object has X (red), Y (green) and Z (blue) rotation rings directly on the artboard. Drag a ring to +adjust that axis; drag inside the rings to rotate freely. Hold Shift to snap to 15-degree increments. +The rings and the rotation fields stay in sync. Escape during a drag restores that gesture and +keeps the settings open. Escape again or Cancel restores the document before the dialog opened. +The gizmo appears while editing Revolve; normal source-path selection returns when the dialog closes. + +Select the **Lighting** tab to use the light gizmo on the artboard. Drag the sun handle to move +its direction on the current hemisphere. A hollow sun marks a back light. Drag the **Direction** +ring or its label horizontally to adjust azimuth; drag the **Elevation** arc or its label vertically +to adjust elevation. The labels remain usable when a ring is viewed edge-on or the light is at a pole. +Hold Shift to snap to 15-degree increments. Light gestures update their matching fields without +rotating the object, and keep the same temporary preview, Escape, Cancel and undo behaviour. +Light intensity and ambient contribution remain available as draggable fields and sliders. + +**Shading** off hides the light gizmo and disables lighting controls. Switch to **Rotation** to return +to the object rings. **Show axis** in **Advanced** displays the source's vertical axis on the object. +Turning **Preview** off restores the original artwork and hides artboard controls until Preview is enabled. +In small windows, the controls scroll while OK and Cancel stay visible. Reset dots restore individual +settings; **Reset all** restores parameters on both tabs. + +Preview changes are temporary until OK. Cancel restores the document; OK creates one undo step. +The source anchors remain editable. Appearance lists the effect for reopening its options. +SVG/PDF export evaluates filled vector faces; native saves retain the profile and effect parameters. +**Object > Expand Appearance** replaces the live effect with ordinary editable vector faces. + +In **Advanced**, **Keep visible surfaces only when expanding** is on by default. Expansion removes +completely covered faces and trims partially covered ones using the current rotation and perspective. +Visible interior surfaces through open ends remain. Uncheck it to retain every projected face. +The checkbox has its own reset dot and is included in **Reset all**. Its saved command parameter is +`expandVisibleOnly`; it does not change the live preview or SVG/PDF export baking. + +Trimming applies to opaque solid paint. Transparent objects, gradients, patterns, non-normal blending, +later geometry effects and surfaces exceeding bounded visibility limits keep their complete geometry +to preserve their appearance. Visibility follows the same painter order as the live effect, including +its approximation for intersecting surfaces. Expansion changes are undoable with the standard command. + +## Initial limits + +- Paths and compound paths only. Type, images and groups must be converted to paths first. +- Open profiles produce uncapped surfaces. Closed profile boundaries are swept as provided. +- One Revolve per source object. Edit its options rather than applying another. +- Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint. +- Flat shading, one directional light, no textures, cast shadows, end caps or physical materials. +- Faces use back-to-front depth sorting. Intersecting profiles can have incorrect visibility. +- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces. +- Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile. +- Extrude & Bevel, Inflate, Rotate and Materials remain unimplemented. + +## Architecture + +`vectorcraft-three-d` depends only on `vectorcraft-geom`. It creates a surface of revolution, +applies Euler view rotation, projects it, shades faces and sorts their depth. It has no UI or GPU dependency. +`vectorcraft-effects` turns those faces into document art. The renderer, export baking and +Expand Appearance use the same evaluator. The standard `effect.apply`, `effect.setParams`, +`effect.remove` and `effect.expandAppearance` commands expose it to the CLI, control channel and MCP. + +Behaviour reference: [public 3D effects documentation](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html). +This implementation uses original mathematics and source code, with no Adobe software, assets or presets. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 000000000..03b9ec6a3 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,85 @@ +# Roadmap: milestones and what's next + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (M7: accurate character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x) + +Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and +[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 +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) | 🟡 Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | +| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | +| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 | +| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; graph Design/Column/Marker designs pending | 6–10 | +| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | +| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 | +| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 | +| | **Total to full parity** (one agent; from [target-app-parity.md](target-app-parity.md)) | | **~360–590** | + +Milestone estimates predate the 2026-10-10 re-measure for M1–M14; the area table in +[target-app-parity.md](target-app-parity.md) is authoritative when they disagree. + +## Progress log before the progress-docs standard + +The ROADMAP's "last updated" line as of 2026-10-10, kept as the record of what landed up to then: + +After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2), Variables (data merge): text and visibility variables, data sets and the Variables panel (#761), a window whose first frame never reaches the screen starting again on the next graphics processor (#964)). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | M7: document-point character sizes, leading and baseline shift, and resettable horizontal stretching after object/group scaling (#1034). Optional group font-size preservation remains separate; scores/hours unchanged. | +| 2026-10-10 | minor | M9 row from main: initial Revolve | +| 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha | +| 2026-10-10 | major | Created from ROADMAP.md's milestone table and "Where we're going"; added Current focus and the beta list | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md new file mode 100644 index 000000000..d6d1f5b8b --- /dev/null +++ b/docs/target-app-parity.md @@ -0,0 +1,465 @@ +# Parity with Adobe Illustrator + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (#1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) + +The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is +the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), +[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, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi, proportional vertical metrics), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 85% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape | 6–10 | estimated (lowered from 88%: target moved) | +| Image Trace, graphs, image tools | 3 | 75% | graph Design/Column/Marker, Image Trace Create Strokes depth (Create Object Mosaic is done) | 5–8 | estimated (raised from 72%) | +| 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, optional font-size preservation during group scaling (scaled character controls fixed in #1034) | 14–19 | +| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | +| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | +| 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 groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into the default tabbed group it doesn't belong to, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. + - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. + - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, German, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). + - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). + - 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). + Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself. + Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786). + - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. + - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. + - **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. +- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. +- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). +- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. +- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). +- **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:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). +- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type, and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (vertical type's `vpal` not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. +- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. +- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. +- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). +- **Paint and appearance (M3):** + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). + - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. + - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. + - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. + - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. + - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. + - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. + - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. +- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. +- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). +- **Files (M4):** + - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). + - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. + - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). + - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). + - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. + - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). + - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). +- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. +- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland. +- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Merged main: initial live Revolve landed (#846). 3D 0% → ~8% (50–85 h), menu stubs 28 → 27; depth, readiness and totals unchanged within rounding | +| 2026-10-10 | minor | Method aligned with the standard, no new evidence: the full ready-for-real-work number is the additive weighted sum over the dimensions (~62%). The multiplicative 55% from earlier today is withdrawn, and the 50% before it was an unbroken-down judgement. Added hours to ~95% for each audience (full 340–560 h, mainstream 230–380 h, essentials 60–105 h). Beta distance is now ~13 points | +| 2026-10-10 | minor | Added the essentials-user readiness score (~75%): 16 core features weighted, depth ~91%, discounted for launch, discoverability and files people send | +| 2026-10-10 | minor | Second look at ready for real work at the owner's request: 50% → 55% full Illustrator, and a new mainstream-illustrator figure of 65%. Why: the old 25-point discount was a judgement with no breakdown. Now each discount is written down and checked against the tracker: 86% of 79 core-drawing issues closed, 20 of 28 `.ai`/EPS issues fixed and the open ones about structure, praise from 15 people, no reports of switching; dimensions a typical illustrator rarely hits are taken out of readiness. Feature breadth, depth and hours unchanged | +| 2026-10-10 | minor | Stage checked against the core-workflow gate: passes, stays alpha | +| 2026-10-10 | minor | #1034: accurate transformed character controls; type-core score/hours and headline estimates unchanged at this rounding. | +| 2026-10-10 | major | Full re-measure against Illustrator 2026 30.x from code counts, open issues and public docs; added breadth/depth/ready-for-real-work numbers, generative-AI row, calibration from PR timestamps; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file | +| 2026-10-05 | major | (in ROADMAP.md) M3 and M4 rows re-derived after the paint and files milestones | +| 2026-10-02 | major | (in ROADMAP.md) First 22-area weighted estimate | diff --git a/docs/type-parity.md b/docs/type-parity.md new file mode 100644 index 000000000..e74f805da --- /dev/null +++ b/docs/type-parity.md @@ -0,0 +1,54 @@ +# Type parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (document-point character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x) + +Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in +[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the +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 | +| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | +| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | +| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference unused (#394) | +| Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339 | +| Variable fonts | partial | named instances as styles; axis sliders missing | +| 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 | minor | #1034: transformed character controls and regression coverage; preserving group font sizes remains a separate enhancement. Scores/hours unchanged at this rounding. | +| 2026-10-10 | major | First checklist, split out of the parity table's Type core and Type advanced rows | diff --git a/docs/ui-parity.md b/docs/ui-parity.md new file mode 100644 index 000000000..ce279c81d --- /dev/null +++ b/docs/ui-parity.md @@ -0,0 +1,169 @@ +# 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: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer | +| Alt swaps a ruler guide's orientation while dragging | missing | | +| Global and video rulers | missing | menu stubs | + +## Transform handles, bounding box, Free Transform + +| 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 | +| Floating groups stack into sets; sets dock as extra dock columns (a multi-column dock); groups drop anywhere in a set or column, or onto its tabs; a column taller than the window scrolls | done | `window.panel.float {below, above}`, `window.panel.dock {column}` | +| Panels outside the app window (another monitor) | missing | single OS window | +| Properties panel per context (every selection type's Quick Actions) | partial | | +| Contextual task bar | done | | +| 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 | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 1036031f2..7efbaf44e 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and package VectorCraft for Linux ( is x86_64 or aarch64): +# Build and package VectorCraft for Linux ( is x86_64 or aarch64, or riscv64 when cross-compiling): # # $DIST/vectorcraft--linux-.AppImage any distro with glibc >= the build host's # $DIST/vectorcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) @@ -9,6 +9,10 @@ # # Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] # +# Cross-compiling (release.yml's linux-riscv64 job): set CROSS_ARCH (riscv64), CROSS_TARGET (the Rust +# target), CROSS_COMPILE (the binutils prefix, for strip) and EMULATOR (e.g. qemu-riscv64, to +# smoke-test the CLI), and pass --formats tar. +# # Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, @@ -25,13 +29,14 @@ while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + -h | --help) sed -n '2,19p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done -ARCH="$(uname -m)" +ARCH="${CROSS_ARCH:-$(uname -m)}" case "$ARCH" in + riscv64) DEB_ARCH=riscv64 ;; x86_64) DEB_ARCH=amd64 ;; aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; @@ -42,9 +47,13 @@ BASENAME="vectorcraft-$VERSION-linux-$ARCH" echo "==> VectorCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then - (cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli) + if [ -n "${CROSS_TARGET:-}" ]; then + (cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli --target "$CROSS_TARGET") + else + (cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli) + fi fi -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" WORK="$CARGO_TARGET_DIR/linux-package" STAGE="$WORK/root" rm -rf "$WORK" @@ -52,7 +61,7 @@ rm -rf "$WORK" # ---- stage an FHS tree (shared by every format) ------------------------------------------------- install -Dm755 "$BIN/vectorcraft" "$STAGE/usr/bin/vectorcraft" install -Dm755 "$BIN/vectorcraft-cli" "$STAGE/usr/bin/vectorcraft-cli" -strip "$STAGE/usr/bin/vectorcraft" "$STAGE/usr/bin/vectorcraft-cli" 2>/dev/null || true +"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/vectorcraft" "$STAGE/usr/bin/vectorcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" mkdir -p "$STAGE/usr/share/metainfo" @@ -135,6 +144,10 @@ if has appimage; then fi fi -"$STAGE/usr/bin/vectorcraft-cli" --version +if [ -z "${CROSS_TARGET:-}" ]; then + "$STAGE/usr/bin/vectorcraft-cli" --version +elif [ -n "${EMULATOR:-}" ]; then + "$EMULATOR" "$STAGE/usr/bin/vectorcraft-cli" --version +fi echo "==> done" ls -lh "$DIST" diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index dde4ca5cd..5e724f03b 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -41,6 +41,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("trace", Class::Layer(2)), ("text", Class::Layer(2)), ("effects", Class::Layer(2)), + ("three-d", Class::Layer(2)), ("render", Class::Layer(3)), ("svg", Class::Layer(3)), ("pdf", Class::Layer(3)), @@ -78,6 +79,7 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[ // Non-native art in Illustrator editing data is a PDF, drawn by the PDF importer. &["pdf", "eps"], &["plugins", "effects"], + &["three-d", "effects"], ]; fn intra_layer_allowed(from: &str, to: &str) -> bool {